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"> + 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..d0239481 100644 --- a/app/src/main/java/com/texthip/thip/MainScreen.kt +++ b/app/src/main/java/com/texthip/thip/MainScreen.kt @@ -3,28 +3,94 @@ 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, notificationData?.fromNotification) { + val data = notificationData + + // 중복 처리 방지 + if (data?.notificationId == processedNotificationId) { + return@LaunchedEffect + } + + data?.let { notificationData -> + if (notificationData.fromNotification && notificationData.notificationId != null) { + try { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + MainScreenEntryPoint::class.java + ) + val notificationRepository = entryPoint.notificationRepository() + + 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) { + 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 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 { 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 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 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..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 @@ -5,10 +5,17 @@ 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.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import javax.inject.Inject import javax.inject.Singleton @@ -17,6 +24,19 @@ class NotificationRepository @Inject constructor( private val notificationService: NotificationService, @param:ApplicationContext private val context: Context ) { + private val _notificationUpdateFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val notificationUpdateFlow: SharedFlow = _notificationUpdateFlow.asSharedFlow() + + private val _notificationRefreshFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val notificationRefreshFlow: SharedFlow = _notificationRefreshFlow.asSharedFlow() suspend fun registerFcmToken( deviceId: String, fcmToken: String @@ -31,7 +51,7 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } - + suspend fun getNotificationEnableState(): Result { return runCatching { val deviceId = context.getAppScopeDeviceId() @@ -39,7 +59,7 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } - + suspend fun updateNotificationEnabled(enabled: Boolean): Result { return runCatching { val deviceId = context.getAppScopeDeviceId() @@ -51,7 +71,7 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } - + suspend fun deleteFcmToken(): Result { return runCatching { val deviceId = context.getAppScopeDeviceId() @@ -60,4 +80,32 @@ class NotificationRepository @Inject constructor( response.handleBaseResponse().getOrNull() } } + + suspend fun getNotifications( + cursor: String? = null, + type: String? = null + ): Result { + return runCatching { + val response = notificationService.getNotifications(cursor, type) + 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들에게 알림 (비차단 emit) + if (result != null) { + _notificationUpdateFlow.tryEmit(notificationId) + } + result + } + } + + fun onNotificationReceived() { + _notificationRefreshFlow.tryEmit(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 3447316d..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,7 +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 @@ -17,19 +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 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..b7ecfa29 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,25 @@ 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}") + // 푸시 알림 도착 시 알림 상태 새로고침 (비차단 방식) + 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 +67,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/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) } ) } 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 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..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 @@ -9,39 +9,96 @@ 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.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.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 = {}, + onNotificationNavigation: (NotificationCheckResponse) -> 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) }, + onNotificationClick = { notificationId -> + viewModel.checkNotification(notificationId) { response -> + onNotificationNavigation(response) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlarmContent( + uiState: AlarmUiState, + onNavigateBack: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onLoadMore: () -> Unit = {}, + onChangeNotificationType: (NotificationType) -> Unit = {}, + onNotificationClick: (Int) -> 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 +109,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 = { + onNotificationClick(notification.notificationId) } - }) + ) + } + } + } 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 +189,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 +238,11 @@ fun AlarmScreenPreview() { @Composable fun AlarmScreenEmptyPreview() { ThipTheme { - AlarmScreen( - alarmItems = emptyList() + AlarmContent( + uiState = AlarmUiState( + notifications = emptyList(), + isLoading = false + ) ) } } \ No newline at end of file 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..24e685ae --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/common/alarmpage/viewmodel/AlarmUiState.kt @@ -0,0 +1,16 @@ +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 + 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 efa44466..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 @@ -1,27 +1,164 @@ 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.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 +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 var loadJob: Job? = null + + 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) + + // Repository의 알림 업데이트 이벤트 구독 + viewModelScope.launch { + repository.notificationUpdateFlow.collect { notificationId -> + updateNotificationAsRead(notificationId) + } + } + + // 푸시 알림 도착 시 새로고침 이벤트 구독 + viewModelScope.launch { + repository.notificationRefreshFlow.collect { + refreshData() + } + } + } + + fun loadNotifications(reset: Boolean = false) { + // reset 시 기존 작업 취소 + if (reset) { + loadJob?.cancel() + loadJob = null + } + + // 중복 로드 방지 (reset이 아닌 경우에만) + if (isLoadingData && !reset) return + if (isLastPage && !reset) return + + // 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에 작업 바인딩 + val currentJob = viewModelScope.launch { + try { + val type = + if (uiState.value.currentNotificationType == NotificationType.FEED_AND_ROOM) { + null + } else { + uiState.value.currentNotificationType.value + } + + repository.getNotifications(nextCursor, type) + .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 { + if (loadJob == coroutineContext[Job]) { + isLoadingData = false + updateState { it.copy(isLoading = false, isLoadingMore = false) } + loadJob = null + } + } + } + loadJob = currentJob + } + + fun loadMoreNotifications() { + loadNotifications(reset = false) + } + + fun refreshData() { + loadNotifications(reset = true) + } + + fun changeNotificationType(notificationType: NotificationType) { + if (notificationType != uiState.value.currentNotificationType) { + updateState { it.copy(currentNotificationType = notificationType) } + 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) } + } + } } - fun onCardClick(item: AlarmItem) { - // TODO: 알림 카드 클릭 처리 + 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 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..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 @@ -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) } @@ -154,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) { // 일반적인 경우: 전체 새로고침 + 스크롤 상단 이동 @@ -177,7 +179,7 @@ fun FeedScreen( isUserTabChange = false } } - + // 같은 탭 재클릭 시 스크롤 상단 이동 처리 LaunchedEffect(shouldScrollToTop) { if (shouldScrollToTop) { @@ -185,7 +187,7 @@ fun FeedScreen( shouldScrollToTop = false } } - + // 중복된 로직 제거 - 기존 bottomNavReselected 방식만 사용 LaunchedEffect(resultFeedId) { @@ -217,11 +219,12 @@ fun FeedScreen( } } } - + // 바텀 네비게이션 탭 재선택 처리 (직접 상태 전달 방식) LaunchedEffect(onFeedTabReselected) { if (onFeedTabReselected > 0) { feedViewModel.refreshOnBottomNavReselect() + alarmViewModel.refreshData() currentListState.scrollToItem(0) } } @@ -256,6 +259,7 @@ fun FeedScreen( FeedContent( feedUiState = feedUiState, + hasUnreadNotifications = alarmUiState.hasUnreadNotifications, showProgressBar = showProgressBar, progress = progress.value, currentListState = currentListState, @@ -282,14 +286,18 @@ fun FeedScreen( }, onChangeFeedLike = feedViewModel::changeFeedLike, onChangeFeedSave = feedViewModel::changeFeedSave, - onPullToRefresh = feedViewModel::pullToRefresh + onPullToRefresh = { + feedViewModel.pullToRefresh() + alarmViewModel.refreshData() + } ) } @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 +339,7 @@ private fun FeedContent( ) { LogoTopAppBar( leftIcon = painterResource(R.drawable.ic_plusfriend), - hasNotification = false, + hasNotification = hasUnreadNotifications, onLeftClick = onNavigateToSearchPeople, onRightClick = onNavigateToNotification, ) @@ -659,6 +667,7 @@ private fun FeedContentPreview() { ) ) ), + hasUnreadNotifications = false, showProgressBar = false, progress = 0f, currentListState = LazyListState(), 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") } 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 033598c9..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 @@ -34,6 +34,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.feed.component.EmptyMySubscriptionBar import com.texthip.thip.ui.group.myroom.component.GroupMySectionHeader @@ -55,18 +56,22 @@ fun GroupScreen( onNavigateToGroupSearch: () -> Unit = {}, // 검색 화면으로 이동 onNavigateToGroupMy: () -> Unit = {}, // 내 모임방 화면으로 이동 onNavigateToGroupRecruit: (Int) -> Unit = {}, // 모집 중인 모임방 화면으로 이동 - onNavigateToGroupRoom: (Int) -> Unit = {}, // 기록장 화면으로 이동, + onNavigateToGroupRoom: (Int) -> Unit = {}, // 기록장 화면으로 이동 onNavigateToGroupSearchAllRooms: () -> Unit = {}, - viewModel: GroupViewModel = hiltViewModel() + viewModel: GroupViewModel = hiltViewModel(), + alarmViewModel: AlarmViewModel = hiltViewModel() ) { // 화면 재진입 시 데이터 새로고침 LaunchedEffect(Unit) { viewModel.resetToInitialState() + alarmViewModel.refreshData() } val uiState by viewModel.uiState.collectAsState() + val alarmUiState by alarmViewModel.uiState.collectAsState() GroupContent( uiState = uiState, + hasUnreadNotifications = alarmUiState.hasUnreadNotifications, onNavigateToMakeRoom = onNavigateToMakeRoom, onNavigateToGroupDone = onNavigateToGroupDone, onNavigateToAlarm = onNavigateToAlarm, @@ -75,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() }, @@ -86,6 +94,7 @@ fun GroupScreen( @Composable fun GroupContent( uiState: GroupUiState, + hasUnreadNotifications: Boolean = false, onNavigateToMakeRoom: () -> Unit = {}, onNavigateToGroupDone: () -> Unit = {}, onNavigateToAlarm: () -> Unit = {}, @@ -136,7 +145,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 ) @@ -175,7 +190,7 @@ fun GroupContent( // 상단바 LogoTopAppBar( leftIcon = painterResource(R.drawable.ic_done), - hasNotification = false, + hasNotification = hasUnreadNotifications, onLeftClick = onNavigateToGroupDone, onRightClick = onNavigateToAlarm ) 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..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 @@ -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" -> stringResource(R.string.notification_permission_required) + else -> stringResource(R.string.push_off) + }, date = toastDateTime, modifier = Modifier.fillMaxWidth() ) 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/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 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..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 @@ -1,14 +1,11 @@ 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.extensions.navigateFromNotification import com.texthip.thip.ui.navigator.routes.CommonRoutes // Common 관련 네비게이션 @@ -18,16 +15,14 @@ fun NavGraphBuilder.commonNavigation( ) { // Alarm 화면 composable { - val alarmViewModel: AlarmViewModel = viewModel() - val alarmItems by alarmViewModel.alarmItems.collectAsState() - AlarmScreen( - alarmItems = alarmItems, - onCardClick = { alarmViewModel.onCardClick(it) }, - onNavigateBack = navigateBack + onNavigateBack = navigateBack, + onNotificationNavigation = { response -> + navController.navigateFromNotification(response) + } ) } - + // 책 요청 화면 composable { RegisterBookScreen( 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 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 이메일로 닉네임과 문의사항을 보내주시면