Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
68decd6
feat: 작품 알림 API 및 데이터 계층 구현
Sadturtleman Aug 27, 2026
b2d30cd
feat: 작품 알림 도메인 모델 및 유스케이스 추가
Sadturtleman Aug 27, 2026
7aa3541
feat: 작품 상세 알림 등록 바텀시트 구현
Sadturtleman Aug 27, 2026
99f3461
feat: 완결·휴재 복귀 알림 목록 화면 구현
Sadturtleman Aug 27, 2026
623fc0d
feat: 알림 설정 화면에 완결·휴재 복귀 알림 진입점 추가
Sadturtleman Aug 27, 2026
c272d24
feat: 알림 목록 편집 및 일괄 삭제 기능 구현
Sadturtleman Aug 27, 2026
c60affa
fix: 삭제 알럿 문구를 처음 선택한 작품 기준으로 수정
Sadturtleman Aug 27, 2026
98604d2
feat: 완결·휴재 복귀 알림 클릭 시 작품 상세로 이동
Sadturtleman Aug 27, 2026
44c3b04
test: 알림 타입 분기 및 식별자 매핑 테스트 추가
Sadturtleman Aug 27, 2026
69f886b
refactor: 푸시 페이로드 파싱을 PushMessage로 분리하고 테스트 추가
Sadturtleman Aug 27, 2026
68ce374
chore: 주석 변경
Sadturtleman Aug 27, 2026
a6267b3
fix: ktlint 형식 오류 수정
Sadturtleman Aug 27, 2026
b1b2869
fix: 알림 설정 저장 및 삭제 후 목록 상태 오류 수정
Sadturtleman Aug 27, 2026
7f9d005
fix: 페이지네이션이 목록 끝에 도달하지 않아도 다음 페이지를 요청하는 문제 수정
Sadturtleman Aug 29, 2026
6b4ba97
refactor: 작품 알림 화면 컴포저블에 상태별 프리뷰 추가
Sadturtleman Aug 29, 2026
9bcf2c9
fix: 작품 알림을 눌러도 읽음 처리가 되지 않는 문제 수정
Sadturtleman Aug 29, 2026
be6f601
fix: 편집 모드에서 앱바 뒤로가기가 화면을 바로 종료하는 문제 수정
Sadturtleman Aug 29, 2026
b7a649c
fix: 취소된 요청이 API 오류로 처리되어 UI 상태가 되돌아가는 문제 수정
Sadturtleman Aug 29, 2026
82a58a8
fix: 토글 직후 바텀시트를 닫으면 저장이 유실되는 문제 수정
Sadturtleman Aug 29, 2026
18be37c
refactor: 작품 알림 바텀시트를 Compose로 전환
Sadturtleman Aug 29, 2026
407551b
feat: 작품 알림 바텀시트 조회 실패 시 재시도 경로 추가
Sadturtleman Aug 29, 2026
0358c8f
fix: 목록 조회 실패가 빈 화면으로 보이는 문제 수정
Sadturtleman Aug 29, 2026
79bc869
fix: 페이지 요청 중 삭제하면 후속 조회가 예약되지 않는 문제 수정
Sadturtleman Aug 29, 2026
d4e930e
fix: 100개 초과 삭제 시 부분 성공이 전체 실패로 처리되는 문제 수정
Sadturtleman Aug 29, 2026
704739c
refactor: 푸시 알림 문구를 문자열 리소스로 분리
Sadturtleman Aug 29, 2026
a5bfe89
chore: 작품 알림 관련 주석 제거
Sadturtleman Aug 29, 2026
1fc483e
fix: ktlint 형식 오류 수정
Sadturtleman Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@
android:name=".ui.notificationSetting.NotificationSettingActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".ui.novelNotification.NovelNotificationListActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".ui.expandedFeedImage.ExpandedFeedImageActivity"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.into.websoso.core.common.util.message

data class PushMessage(
val title: String?,
val body: String?,
val feedId: Long?,
val novelId: Long?,
val notificationId: Long,
) {
val destination: PushDestination
get() = when {
feedId != null -> PushDestination.FEED
novelId != null -> PushDestination.NOVEL
else -> PushDestination.NOTIFICATION_DETAIL
}

companion object {
fun from(data: Map<String, String>): PushMessage? {
val notificationId = data["notificationId"]?.toLongOrNull() ?: return null

return PushMessage(
title = data["title"],
body = data["body"],
feedId = data["feedId"]?.toLongOrNull(),
novelId = data["novelId"]?.toLongOrNull(),
notificationId = notificationId,
)
}
}
}

enum class PushDestination {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PushDestination을 PushMessage와 같은 파일에 둔 이유가 있을까요?

현재는 PushMessage에 종속적인 타입이라 함께 두신 것 같기도 한데, 이후 목적지가 추가되거나 다른 곳에서도 사용될 가능성을 고려하면 별도 파일로 분리하는 방식도 괜찮을 것 같아 의견 여쭤봅니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

종속적인 타입이라 이렇게 두었고 푸시가 다른 곳에서도 다른 로직으로 처리된다면 이런 설계가 아닌 다른 방식으로 설계를 해야 할 것 같아 현재는 이렇게 두었습니다. 지금은 pushmessage, pushdestination이 확장에 열려있는 설계는 아니지만 추후 어떤 범위로 확장이 될지 몰라 그때 재설계하는게 좋다 생각했습니다

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 확장성을 고려해 분리를 선호하는 스타일이라 궁금했어요! 👍🏻

FEED,
NOVEL,
NOTIFICATION_DETAIL,
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.into.websoso.core.resource.R.mipmap.ic_wss_logo
import com.into.websoso.core.resource.R.string.app_name
import com.into.websoso.core.resource.R.string.push_notification_channel_description
import com.into.websoso.core.resource.R.string.push_notification_default_body
import com.into.websoso.data.repository.PushMessageRepository
import com.into.websoso.ui.feedDetail.FeedDetailActivity
import com.into.websoso.ui.main.MainActivity
import com.into.websoso.ui.notificationDetail.NotificationDetailActivity
import com.into.websoso.ui.novelDetail.NovelDetailActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -27,44 +31,51 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)

val receivedData = message.data
if (receivedData.isEmpty()) return

val title = receivedData["title"] ?: DEFAULT_TITLE
val body = receivedData["body"] ?: DEFAULT_BODY
val feedId = receivedData["feedId"]?.toLongOrNull()
val notificationId = receivedData["notificationId"]?.toLong() ?: return
val pushMessage = PushMessage.from(message.data) ?: return

setupNotificationChannel()
val pendingIntent = createPendingIntent(
feedId,
notificationId,
val pendingIntent = createPendingIntent(pushMessage)
showNotification(
title = pushMessage.title ?: getString(app_name),
body = pushMessage.body ?: getString(push_notification_default_body),
pendingIntent = pendingIntent,
)
showNotification(title, body, pendingIntent)
}

private fun setupNotificationChannel() {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
getString(app_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = CHANNEL_DESCRIPTION
description = getString(push_notification_channel_description)
}
notificationManager.createNotificationChannel(channel)
}

private fun createPendingIntent(
feedId: Long?,
notificationId: Long,
): PendingIntent {
private fun createPendingIntent(pushMessage: PushMessage): PendingIntent {
val mainIntent = MainActivity.getIntent(this)
val notificationId = pushMessage.notificationId

val detailIntent = when (pushMessage.destination) {
PushDestination.FEED -> FeedDetailActivity.getIntent(
this,
requireNotNull(pushMessage.feedId),
notificationId,
)

val detailIntent = when {
feedId != null -> FeedDetailActivity.getIntent(this, feedId, notificationId)
else -> NotificationDetailActivity.getIntent(this, notificationId)
PushDestination.NOVEL -> NovelDetailActivity.getIntent(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

작품 푸시 알림을 눌렀을 때도 읽음 처리가 되는지 확인이 필요해 보입니다.

피드 푸시는 FeedDetailActivity에 notificationId를 전달해 읽음 처리하고 있지만, 작품 푸시는 NovelDetailActivity에 novelId만 전달하고 있습니다. 이 경우 푸시를 눌러 작품 상세로 이동해도 해당 알림은 읽지 않은 상태로 남을 수 있을 것 같아요!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다! 푸시뿐만이 아니라 다른 경로에서도 읽음 처리가 되지 않아 같이 수정했습니다

this,
requireNotNull(pushMessage.novelId),
notificationId,
)

PushDestination.NOTIFICATION_DETAIL -> NotificationDetailActivity.getIntent(
this,
notificationId,
)
}

return TaskStackBuilder.create(this).run {
Expand Down Expand Up @@ -108,10 +119,6 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() {
}

companion object {
private const val DEFAULT_TITLE = "웹소소"
private const val DEFAULT_BODY = "푸시 알림 메시지입니다"
private const val CHANNEL_ID = "websoso"
private const val CHANNEL_NAME = "웹소소"
private const val CHANNEL_DESCRIPTION = "웹소소 알림입니다."
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/com/into/websoso/data/di/ApiModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.into.websoso.data.remote.api.FeedApi
import com.into.websoso.data.remote.api.KeywordApi
import com.into.websoso.data.remote.api.NotificationApi
import com.into.websoso.data.remote.api.NovelApi
import com.into.websoso.data.remote.api.NovelNotificationApi
import com.into.websoso.data.remote.api.PushMessageApi
import com.into.websoso.data.remote.api.UserApi
import com.into.websoso.data.remote.api.UserNovelApi
Expand Down Expand Up @@ -36,6 +37,10 @@ object ApiModule {
@Singleton
fun provideNotificationApi(retrofit: Retrofit): NotificationApi = retrofit.create(NotificationApi::class.java)

@Provides
@Singleton
fun provideNovelNotificationApi(retrofit: Retrofit): NovelNotificationApi = retrofit.create(NovelNotificationApi::class.java)

@Provides
@Singleton
fun provideFeedApi(retrofit: Retrofit): FeedApi = retrofit.create(FeedApi::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ fun NotificationsResponseDto.toData(): NotificationsEntity =
isRead = it.isRead,
isNotice = it.isNotice,
feedId = it.feedId,
novelId = it.novelId,
)
},
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.into.websoso.data.mapper

import com.into.websoso.data.model.NovelNotificationSettingEntity
import com.into.websoso.data.model.NovelNotificationSubscriptionEntity
import com.into.websoso.data.model.NovelNotificationSubscriptionsEntity
import com.into.websoso.data.remote.response.NovelNotificationSettingResponseDto
import com.into.websoso.data.remote.response.NovelNotificationSubscriptionsResponseDto

fun NovelNotificationSettingResponseDto.toData(): NovelNotificationSettingEntity =
NovelNotificationSettingEntity(
isCompletionNotificationEnabled = isCompletionNotificationEnabled,
isHiatusReturnNotificationEnabled = isHiatusReturnNotificationEnabled,
)

fun NovelNotificationSubscriptionsResponseDto.toData(): NovelNotificationSubscriptionsEntity =
NovelNotificationSubscriptionsEntity(
isLoadable = isLoadable,
nextSubscriptionId = nextSubscriptionId,
subscriptions = subscriptions.map {
NovelNotificationSubscriptionEntity(
subscriptionId = it.subscriptionId,
novelId = it.novelId,
novelTitle = it.novelTitle,
novelAuthor = it.novelAuthor,
novelImage = it.novelImage,
registeredDate = it.registeredDate,
)
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ data class NotificationEntity(
val isRead: Boolean,
val isNotice: Boolean,
val feedId: Long?,
val novelId: Long?,
) {
fun getIntrinsicId(): Long =
when {
isNotice -> notificationId
feedId != null -> feedId
novelId != null -> novelId
else -> DEFAULT_INTRINSIC_ID
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.into.websoso.data.model

data class NovelNotificationSettingEntity(
val isCompletionNotificationEnabled: Boolean,
val isHiatusReturnNotificationEnabled: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.into.websoso.data.model

data class NovelNotificationSubscriptionEntity(
val subscriptionId: Long,
val novelId: Long,
val novelTitle: String,
val novelAuthor: String,
val novelImage: String,
val registeredDate: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.into.websoso.data.model

data class NovelNotificationSubscriptionsEntity(
val isLoadable: Boolean,
val nextSubscriptionId: Long?,
val subscriptions: List<NovelNotificationSubscriptionEntity>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.into.websoso.data.remote.api

import com.into.websoso.data.remote.request.NovelNotificationSettingRequestDto
import com.into.websoso.data.remote.request.NovelNotificationSubscriptionsDeleteRequestDto
import com.into.websoso.data.remote.response.NovelNotificationSettingResponseDto
import com.into.websoso.data.remote.response.NovelNotificationSubscriptionsResponseDto
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query

interface NovelNotificationApi {
@GET("novels/{novelId}/notification")
suspend fun getNovelNotificationSetting(
@Path("novelId") novelId: Long,
): NovelNotificationSettingResponseDto

@PUT("novels/{novelId}/notification")
suspend fun putNovelNotificationSetting(
@Path("novelId") novelId: Long,
@Body novelNotificationSettingRequestDto: NovelNotificationSettingRequestDto,
)

@GET("users/me/notification/novels")
suspend fun getNovelNotificationSubscriptions(
@Query("notificationType") notificationType: String,
@Query("lastSubscriptionId") lastSubscriptionId: Long,
@Query("size") size: Int,
): NovelNotificationSubscriptionsResponseDto

@HTTP(method = "DELETE", path = "users/me/notification/novels", hasBody = true)
suspend fun deleteNovelNotificationSubscriptions(
@Body novelNotificationSubscriptionsDeleteRequestDto: NovelNotificationSubscriptionsDeleteRequestDto,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.into.websoso.data.remote.request

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class NovelNotificationSettingRequestDto(
@SerialName("isCompletionNotificationEnabled")
val isCompletionNotificationEnabled: Boolean,
@SerialName("isHiatusReturnNotificationEnabled")
val isHiatusReturnNotificationEnabled: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.into.websoso.data.remote.request

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class NovelNotificationSubscriptionsDeleteRequestDto(
@SerialName("notificationType")
val notificationType: String,
@SerialName("novelIds")
val novelIds: List<Long>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,7 @@ data class NotificationsResponseDto(
val isNotice: Boolean,
@SerialName("feedId")
val feedId: Long?,
@SerialName("novelId")
val novelId: Long? = null,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.into.websoso.data.remote.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class NovelNotificationSettingResponseDto(
@SerialName("isCompletionNotificationEnabled")
val isCompletionNotificationEnabled: Boolean,
@SerialName("isHiatusReturnNotificationEnabled")
val isHiatusReturnNotificationEnabled: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.into.websoso.data.remote.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class NovelNotificationSubscriptionsResponseDto(
@SerialName("isLoadable")
val isLoadable: Boolean,
@SerialName("nextSubscriptionId")
val nextSubscriptionId: Long? = null,
@SerialName("subscriptions")
val subscriptions: List<NovelNotificationSubscriptionResponseDto>,
) {
@Serializable
data class NovelNotificationSubscriptionResponseDto(
@SerialName("subscriptionId")
val subscriptionId: Long,
@SerialName("novelId")
val novelId: Long,
@SerialName("novelTitle")
val novelTitle: String,
@SerialName("novelAuthor")
val novelAuthor: String,
@SerialName("novelImage")
val novelImage: String,
@SerialName("registeredDate")
val registeredDate: String,
)
}
Loading
Loading