diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2446f0f1a..f8a135426 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -156,6 +156,10 @@
android:name=".ui.notificationSetting.NotificationSettingActivity"
android:exported="false"
android:screenOrientation="portrait" />
+
PushDestination.FEED
+ novelId != null -> PushDestination.NOVEL
+ else -> PushDestination.NOTIFICATION_DETAIL
+ }
+
+ companion object {
+ fun from(data: Map): 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 {
+ FEED,
+ NOVEL,
+ NOTIFICATION_DETAIL,
+}
diff --git a/app/src/main/java/com/into/websoso/core/common/util/message/WSSFirebaseMessagingService.kt b/app/src/main/java/com/into/websoso/core/common/util/message/WSSFirebaseMessagingService.kt
index a27217646..be98f7b85 100644
--- a/app/src/main/java/com/into/websoso/core/common/util/message/WSSFirebaseMessagingService.kt
+++ b/app/src/main/java/com/into/websoso/core/common/util/message/WSSFirebaseMessagingService.kt
@@ -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
@@ -27,20 +31,15 @@ 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() {
@@ -48,23 +47,35 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() {
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(
+ this,
+ requireNotNull(pushMessage.novelId),
+ notificationId,
+ )
+
+ PushDestination.NOTIFICATION_DETAIL -> NotificationDetailActivity.getIntent(
+ this,
+ notificationId,
+ )
}
return TaskStackBuilder.create(this).run {
@@ -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 = "웹소소 알림입니다."
}
}
diff --git a/app/src/main/java/com/into/websoso/data/di/ApiModule.kt b/app/src/main/java/com/into/websoso/data/di/ApiModule.kt
index 8f303f6d5..af8fdca1e 100644
--- a/app/src/main/java/com/into/websoso/data/di/ApiModule.kt
+++ b/app/src/main/java/com/into/websoso/data/di/ApiModule.kt
@@ -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
@@ -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)
diff --git a/app/src/main/java/com/into/websoso/data/mapper/NotificationMapper.kt b/app/src/main/java/com/into/websoso/data/mapper/NotificationMapper.kt
index 282442b79..abe6b7d82 100644
--- a/app/src/main/java/com/into/websoso/data/mapper/NotificationMapper.kt
+++ b/app/src/main/java/com/into/websoso/data/mapper/NotificationMapper.kt
@@ -19,6 +19,7 @@ fun NotificationsResponseDto.toData(): NotificationsEntity =
isRead = it.isRead,
isNotice = it.isNotice,
feedId = it.feedId,
+ novelId = it.novelId,
)
},
)
diff --git a/app/src/main/java/com/into/websoso/data/mapper/NovelNotificationMapper.kt b/app/src/main/java/com/into/websoso/data/mapper/NovelNotificationMapper.kt
new file mode 100644
index 000000000..9cee41abc
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/mapper/NovelNotificationMapper.kt
@@ -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,
+ )
+ },
+ )
diff --git a/app/src/main/java/com/into/websoso/data/model/NotificationEntity.kt b/app/src/main/java/com/into/websoso/data/model/NotificationEntity.kt
index 8b3e765b1..0881de5dc 100644
--- a/app/src/main/java/com/into/websoso/data/model/NotificationEntity.kt
+++ b/app/src/main/java/com/into/websoso/data/model/NotificationEntity.kt
@@ -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
}
}
diff --git a/app/src/main/java/com/into/websoso/data/model/NovelNotificationSettingEntity.kt b/app/src/main/java/com/into/websoso/data/model/NovelNotificationSettingEntity.kt
new file mode 100644
index 000000000..c40ca00c4
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/model/NovelNotificationSettingEntity.kt
@@ -0,0 +1,6 @@
+package com.into.websoso.data.model
+
+data class NovelNotificationSettingEntity(
+ val isCompletionNotificationEnabled: Boolean,
+ val isHiatusReturnNotificationEnabled: Boolean,
+)
diff --git a/app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionEntity.kt b/app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionEntity.kt
new file mode 100644
index 000000000..23fa06b9e
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionsEntity.kt b/app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionsEntity.kt
new file mode 100644
index 000000000..5bf30080b
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionsEntity.kt
@@ -0,0 +1,7 @@
+package com.into.websoso.data.model
+
+data class NovelNotificationSubscriptionsEntity(
+ val isLoadable: Boolean,
+ val nextSubscriptionId: Long?,
+ val subscriptions: List,
+)
diff --git a/app/src/main/java/com/into/websoso/data/remote/api/NovelNotificationApi.kt b/app/src/main/java/com/into/websoso/data/remote/api/NovelNotificationApi.kt
new file mode 100644
index 000000000..e8ab51bcf
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/remote/api/NovelNotificationApi.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSettingRequestDto.kt b/app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSettingRequestDto.kt
new file mode 100644
index 000000000..cecb393f2
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSettingRequestDto.kt
@@ -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,
+)
diff --git a/app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSubscriptionsDeleteRequestDto.kt b/app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSubscriptionsDeleteRequestDto.kt
new file mode 100644
index 000000000..3cc9f0b3c
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSubscriptionsDeleteRequestDto.kt
@@ -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,
+)
diff --git a/app/src/main/java/com/into/websoso/data/remote/response/NotificationsResponseDto.kt b/app/src/main/java/com/into/websoso/data/remote/response/NotificationsResponseDto.kt
index 9b6f28e30..9e182706f 100644
--- a/app/src/main/java/com/into/websoso/data/remote/response/NotificationsResponseDto.kt
+++ b/app/src/main/java/com/into/websoso/data/remote/response/NotificationsResponseDto.kt
@@ -28,5 +28,7 @@ data class NotificationsResponseDto(
val isNotice: Boolean,
@SerialName("feedId")
val feedId: Long?,
+ @SerialName("novelId")
+ val novelId: Long? = null,
)
}
diff --git a/app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSettingResponseDto.kt b/app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSettingResponseDto.kt
new file mode 100644
index 000000000..42faa9f28
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSettingResponseDto.kt
@@ -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,
+)
diff --git a/app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSubscriptionsResponseDto.kt b/app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSubscriptionsResponseDto.kt
new file mode 100644
index 000000000..e1bc8fcac
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSubscriptionsResponseDto.kt
@@ -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,
+) {
+ @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,
+ )
+}
diff --git a/app/src/main/java/com/into/websoso/data/repository/NovelNotificationRepository.kt b/app/src/main/java/com/into/websoso/data/repository/NovelNotificationRepository.kt
new file mode 100644
index 000000000..e130123f4
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/data/repository/NovelNotificationRepository.kt
@@ -0,0 +1,56 @@
+package com.into.websoso.data.repository
+
+import com.into.websoso.data.mapper.toData
+import com.into.websoso.data.model.NovelNotificationSettingEntity
+import com.into.websoso.data.model.NovelNotificationSubscriptionsEntity
+import com.into.websoso.data.remote.api.NovelNotificationApi
+import com.into.websoso.data.remote.request.NovelNotificationSettingRequestDto
+import com.into.websoso.data.remote.request.NovelNotificationSubscriptionsDeleteRequestDto
+import javax.inject.Inject
+
+class NovelNotificationRepository
+ @Inject
+ constructor(
+ private val novelNotificationApi: NovelNotificationApi,
+ ) {
+ suspend fun fetchNovelNotificationSetting(novelId: Long): NovelNotificationSettingEntity =
+ novelNotificationApi.getNovelNotificationSetting(novelId).toData()
+
+ suspend fun saveNovelNotificationSetting(
+ novelId: Long,
+ isCompletionNotificationEnabled: Boolean,
+ isHiatusReturnNotificationEnabled: Boolean,
+ ) {
+ novelNotificationApi.putNovelNotificationSetting(
+ novelId = novelId,
+ novelNotificationSettingRequestDto = NovelNotificationSettingRequestDto(
+ isCompletionNotificationEnabled = isCompletionNotificationEnabled,
+ isHiatusReturnNotificationEnabled = isHiatusReturnNotificationEnabled,
+ ),
+ )
+ }
+
+ suspend fun fetchNovelNotificationSubscriptions(
+ notificationType: String,
+ lastSubscriptionId: Long,
+ size: Int,
+ ): NovelNotificationSubscriptionsEntity =
+ novelNotificationApi
+ .getNovelNotificationSubscriptions(
+ notificationType = notificationType,
+ lastSubscriptionId = lastSubscriptionId,
+ size = size,
+ ).toData()
+
+ suspend fun deleteNovelNotificationSubscriptions(
+ notificationType: String,
+ novelIds: List,
+ ) {
+ novelNotificationApi.deleteNovelNotificationSubscriptions(
+ novelNotificationSubscriptionsDeleteRequestDto = NovelNotificationSubscriptionsDeleteRequestDto(
+ notificationType = notificationType,
+ novelIds = novelIds,
+ ),
+ )
+ }
+ }
diff --git a/app/src/main/java/com/into/websoso/domain/mapper/NotificationMapper.kt b/app/src/main/java/com/into/websoso/domain/mapper/NotificationMapper.kt
index 6c2ec97b9..fc2134c0d 100644
--- a/app/src/main/java/com/into/websoso/domain/mapper/NotificationMapper.kt
+++ b/app/src/main/java/com/into/websoso/domain/mapper/NotificationMapper.kt
@@ -22,5 +22,6 @@ fun NotificationEntity.toDomain(): Notification =
isRead = isRead,
isNotice = isNotice,
feedId = feedId,
+ novelId = novelId,
intrinsicId = getIntrinsicId(),
)
diff --git a/app/src/main/java/com/into/websoso/domain/mapper/NovelNotificationMapper.kt b/app/src/main/java/com/into/websoso/domain/mapper/NovelNotificationMapper.kt
new file mode 100644
index 000000000..d1c4d98f3
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/mapper/NovelNotificationMapper.kt
@@ -0,0 +1,32 @@
+package com.into.websoso.domain.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.domain.model.NovelNotificationSetting
+import com.into.websoso.domain.model.NovelNotificationSubscription
+import com.into.websoso.domain.model.NovelNotificationSubscriptions
+import com.into.websoso.domain.model.NovelNotificationSubscriptions.Companion.DEFAULT_SUBSCRIPTION_ID
+
+fun NovelNotificationSettingEntity.toDomain(): NovelNotificationSetting =
+ NovelNotificationSetting(
+ isCompletionNotificationEnabled = isCompletionNotificationEnabled,
+ isHiatusReturnNotificationEnabled = isHiatusReturnNotificationEnabled,
+ )
+
+fun NovelNotificationSubscriptionsEntity.toDomain(): NovelNotificationSubscriptions =
+ NovelNotificationSubscriptions(
+ isLoadable = isLoadable,
+ nextSubscriptionId = nextSubscriptionId ?: DEFAULT_SUBSCRIPTION_ID,
+ subscriptions = subscriptions.map { it.toDomain() },
+ )
+
+fun NovelNotificationSubscriptionEntity.toDomain(): NovelNotificationSubscription =
+ NovelNotificationSubscription(
+ subscriptionId = subscriptionId,
+ novelId = novelId,
+ novelTitle = novelTitle,
+ novelAuthor = novelAuthor,
+ novelImage = novelImage,
+ registeredDate = registeredDate,
+ )
diff --git a/app/src/main/java/com/into/websoso/domain/model/Notification.kt b/app/src/main/java/com/into/websoso/domain/model/Notification.kt
index d55ebc56e..879d0d07e 100644
--- a/app/src/main/java/com/into/websoso/domain/model/Notification.kt
+++ b/app/src/main/java/com/into/websoso/domain/model/Notification.kt
@@ -9,6 +9,7 @@ data class Notification(
val isRead: Boolean,
val isNotice: Boolean,
val feedId: Long?,
+ val novelId: Long?,
val intrinsicId: Long,
) {
fun getNotificationType(): NotificationType =
@@ -16,6 +17,7 @@ data class Notification(
when {
isNotice -> "NOTICE"
feedId != null -> "FEED"
+ novelId != null -> "NOVEL"
else -> "NONE"
},
)
diff --git a/app/src/main/java/com/into/websoso/domain/model/NotificationType.kt b/app/src/main/java/com/into/websoso/domain/model/NotificationType.kt
index 7f3feb7f9..c7fa3465e 100644
--- a/app/src/main/java/com/into/websoso/domain/model/NotificationType.kt
+++ b/app/src/main/java/com/into/websoso/domain/model/NotificationType.kt
@@ -3,6 +3,7 @@ package com.into.websoso.domain.model
enum class NotificationType {
NOTICE,
FEED,
+ NOVEL,
NONE,
;
@@ -11,6 +12,7 @@ enum class NotificationType {
when (value) {
"NOTICE" -> NOTICE
"FEED" -> FEED
+ "NOVEL" -> NOVEL
else -> NONE
}
}
diff --git a/app/src/main/java/com/into/websoso/domain/model/NovelNotificationDeleteResult.kt b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationDeleteResult.kt
new file mode 100644
index 000000000..6a42daba8
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationDeleteResult.kt
@@ -0,0 +1,10 @@
+package com.into.websoso.domain.model
+
+/**
+ * 삭제는 100개씩 나눠 순차 요청하므로 앞쪽 요청만 성공한 채 실패할 수 있다.
+ * 이때 서버에서 이미 지워진 항목을 화면에 남겨두지 않도록 실제로 삭제된 목록을 함께 돌려준다.
+ */
+data class NovelNotificationDeleteResult(
+ val deletedNovelIds: List,
+ val isCompleted: Boolean,
+)
diff --git a/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSetting.kt b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSetting.kt
new file mode 100644
index 000000000..ad612c306
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSetting.kt
@@ -0,0 +1,6 @@
+package com.into.websoso.domain.model
+
+data class NovelNotificationSetting(
+ val isCompletionNotificationEnabled: Boolean = false,
+ val isHiatusReturnNotificationEnabled: Boolean = false,
+)
diff --git a/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscription.kt b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscription.kt
new file mode 100644
index 000000000..b05f76155
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscription.kt
@@ -0,0 +1,10 @@
+package com.into.websoso.domain.model
+
+data class NovelNotificationSubscription(
+ val subscriptionId: Long,
+ val novelId: Long,
+ val novelTitle: String,
+ val novelAuthor: String,
+ val novelImage: String,
+ val registeredDate: String,
+)
diff --git a/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscriptions.kt b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscriptions.kt
new file mode 100644
index 000000000..73a6f6214
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscriptions.kt
@@ -0,0 +1,11 @@
+package com.into.websoso.domain.model
+
+data class NovelNotificationSubscriptions(
+ val isLoadable: Boolean = true,
+ val nextSubscriptionId: Long = DEFAULT_SUBSCRIPTION_ID,
+ val subscriptions: List = emptyList(),
+) {
+ companion object {
+ const val DEFAULT_SUBSCRIPTION_ID = 0L
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/domain/model/NovelNotificationType.kt b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationType.kt
new file mode 100644
index 000000000..fb9b1cc66
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/model/NovelNotificationType.kt
@@ -0,0 +1,15 @@
+package com.into.websoso.domain.model
+
+enum class NovelNotificationType {
+ COMPLETION,
+ HIATUS_RETURN,
+ ;
+
+ companion object {
+ fun from(value: String): NovelNotificationType =
+ when (value) {
+ HIATUS_RETURN.name -> HIATUS_RETURN
+ else -> COMPLETION
+ }
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt
new file mode 100644
index 000000000..f60bf8ad5
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt
@@ -0,0 +1,53 @@
+package com.into.websoso.domain.usecase
+
+import com.into.websoso.data.repository.NovelNotificationRepository
+import com.into.websoso.domain.model.NovelNotificationDeleteResult
+import com.into.websoso.domain.model.NovelNotificationType
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+
+class DeleteNovelNotificationSubscriptionsUseCase
+ @Inject
+ constructor(
+ private val novelNotificationRepository: NovelNotificationRepository,
+ ) {
+ suspend operator fun invoke(
+ notificationType: NovelNotificationType,
+ novelIds: List,
+ ): Result {
+ val deletedNovelIds = mutableListOf()
+
+ return try {
+ novelIds.chunked(MAX_DELETABLE_SIZE).forEach { chunkedNovelIds ->
+ novelNotificationRepository.deleteNovelNotificationSubscriptions(
+ notificationType = notificationType.name,
+ novelIds = chunkedNovelIds,
+ )
+ deletedNovelIds += chunkedNovelIds
+ }
+ Result.success(
+ NovelNotificationDeleteResult(
+ deletedNovelIds = deletedNovelIds,
+ isCompleted = true,
+ ),
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ when (deletedNovelIds.isEmpty()) {
+ true -> Result.failure(e)
+
+ false -> Result.success(
+ NovelNotificationDeleteResult(
+ deletedNovelIds = deletedNovelIds,
+ isCompleted = false,
+ ),
+ )
+ }
+ }
+ }
+
+ companion object {
+ private const val MAX_DELETABLE_SIZE = 100
+ }
+ }
diff --git a/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt
new file mode 100644
index 000000000..b2b72cbf2
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt
@@ -0,0 +1,24 @@
+package com.into.websoso.domain.usecase
+
+import com.into.websoso.data.repository.NovelNotificationRepository
+import com.into.websoso.domain.mapper.toDomain
+import com.into.websoso.domain.model.NovelNotificationSetting
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+
+class GetNovelNotificationSettingUseCase
+ @Inject
+ constructor(
+ private val novelNotificationRepository: NovelNotificationRepository,
+ ) {
+ suspend operator fun invoke(novelId: Long): Result =
+ try {
+ Result.success(
+ novelNotificationRepository.fetchNovelNotificationSetting(novelId).toDomain(),
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
diff --git a/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt
new file mode 100644
index 000000000..61154877c
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt
@@ -0,0 +1,42 @@
+package com.into.websoso.domain.usecase
+
+import com.into.websoso.data.repository.NovelNotificationRepository
+import com.into.websoso.domain.mapper.toDomain
+import com.into.websoso.domain.model.NovelNotificationSubscriptions
+import com.into.websoso.domain.model.NovelNotificationSubscriptions.Companion.DEFAULT_SUBSCRIPTION_ID
+import com.into.websoso.domain.model.NovelNotificationType
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+
+class GetNovelNotificationSubscriptionsUseCase
+ @Inject
+ constructor(
+ private val novelNotificationRepository: NovelNotificationRepository,
+ ) {
+ suspend operator fun invoke(
+ notificationType: NovelNotificationType,
+ lastSubscriptionId: Long = DEFAULT_SUBSCRIPTION_ID,
+ ): Result =
+ try {
+ val size = when (lastSubscriptionId == DEFAULT_SUBSCRIPTION_ID) {
+ true -> DEFAULT_LOAD_SIZE
+ false -> ADDITIONAL_LOAD_SIZE
+ }
+ val subscriptions = novelNotificationRepository
+ .fetchNovelNotificationSubscriptions(
+ notificationType = notificationType.name,
+ lastSubscriptionId = lastSubscriptionId,
+ size = size,
+ ).toDomain()
+ Result.success(subscriptions)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+
+ companion object {
+ private const val DEFAULT_LOAD_SIZE = 20
+ private const val ADDITIONAL_LOAD_SIZE = 10
+ }
+ }
diff --git a/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt b/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt
new file mode 100644
index 000000000..ab84c1f00
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt
@@ -0,0 +1,29 @@
+package com.into.websoso.domain.usecase
+
+import com.into.websoso.data.repository.NovelNotificationRepository
+import com.into.websoso.domain.model.NovelNotificationSetting
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+
+class UpdateNovelNotificationSettingUseCase
+ @Inject
+ constructor(
+ private val novelNotificationRepository: NovelNotificationRepository,
+ ) {
+ suspend operator fun invoke(
+ novelId: Long,
+ novelNotificationSetting: NovelNotificationSetting,
+ ): Result =
+ try {
+ novelNotificationRepository.saveNovelNotificationSetting(
+ novelId = novelId,
+ isCompletionNotificationEnabled = novelNotificationSetting.isCompletionNotificationEnabled,
+ isHiatusReturnNotificationEnabled = novelNotificationSetting.isHiatusReturnNotificationEnabled,
+ )
+ Result.success(Unit)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
diff --git a/app/src/main/java/com/into/websoso/ui/mapper/NovelNotificationMapper.kt b/app/src/main/java/com/into/websoso/ui/mapper/NovelNotificationMapper.kt
new file mode 100644
index 000000000..f1671040a
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/mapper/NovelNotificationMapper.kt
@@ -0,0 +1,14 @@
+package com.into.websoso.ui.mapper
+
+import com.into.websoso.domain.model.NovelNotificationSubscription
+import com.into.websoso.ui.novelNotification.model.NovelNotificationSubscriptionModel
+
+fun NovelNotificationSubscription.toUi(): NovelNotificationSubscriptionModel =
+ NovelNotificationSubscriptionModel(
+ subscriptionId = subscriptionId,
+ novelId = novelId,
+ novelTitle = novelTitle,
+ novelAuthor = novelAuthor,
+ novelImage = novelImage,
+ registeredDate = registeredDate,
+ )
diff --git a/app/src/main/java/com/into/websoso/ui/notification/NotificationActivity.kt b/app/src/main/java/com/into/websoso/ui/notification/NotificationActivity.kt
index 15f1f718a..37a4d51e5 100644
--- a/app/src/main/java/com/into/websoso/ui/notification/NotificationActivity.kt
+++ b/app/src/main/java/com/into/websoso/ui/notification/NotificationActivity.kt
@@ -19,6 +19,7 @@ import com.into.websoso.core.designsystem.theme.WebsosoTheme
import com.into.websoso.ui.feedDetail.FeedDetailActivity
import com.into.websoso.ui.notification.model.NotificationModel
import com.into.websoso.ui.notificationDetail.NotificationDetailActivity
+import com.into.websoso.ui.novelDetail.NovelDetailActivity
import com.into.websoso.ui.setting.dialog.NotificationPermissionDialog
import dagger.hilt.android.AndroidEntryPoint
@@ -56,6 +57,7 @@ class NotificationActivity : AppCompatActivity() {
viewModel = notificationViewModel,
onNotificationDetailClick = ::navigateToNotificationDetail,
onFeedDetailClick = ::navigateToFeedDetail,
+ onNovelDetailClick = ::navigateToNovelDetail,
onBackButtonClick = {
setResult(ResultFrom.Notification.RESULT_OK)
finish()
@@ -111,6 +113,11 @@ class NotificationActivity : AppCompatActivity() {
startActivity(FeedDetailActivity.getIntent(this, notification.intrinsicId, notification.id))
}
+ private fun navigateToNovelDetail(notification: NotificationModel) {
+ notificationViewModel.updateReadNotification(notification.id)
+ startActivity(NovelDetailActivity.getIntent(this, notification.intrinsicId, notification.id))
+ }
+
companion object {
fun getIntent(context: Context): Intent = Intent(context, NotificationActivity::class.java)
}
diff --git a/app/src/main/java/com/into/websoso/ui/notification/NotificationScreen.kt b/app/src/main/java/com/into/websoso/ui/notification/NotificationScreen.kt
index 9d1709d2d..6439e0c77 100644
--- a/app/src/main/java/com/into/websoso/ui/notification/NotificationScreen.kt
+++ b/app/src/main/java/com/into/websoso/ui/notification/NotificationScreen.kt
@@ -21,6 +21,7 @@ fun NotificationScreen(
viewModel: NotificationViewModel,
onNotificationDetailClick: (NotificationModel) -> Unit,
onFeedDetailClick: (NotificationModel) -> Unit,
+ onNovelDetailClick: (NotificationModel) -> Unit,
onBackButtonClick: () -> Unit,
) {
val uiState by viewModel.notificationUIState.collectAsStateWithLifecycle()
@@ -42,6 +43,7 @@ fun NotificationScreen(
updateNotifications = viewModel::updateNotifications,
onNotificationDetailClick = onNotificationDetailClick,
onFeedDetailClick = onFeedDetailClick,
+ onNovelDetailClick = onNovelDetailClick,
)
}
}
diff --git a/app/src/main/java/com/into/websoso/ui/notification/component/NotificationsContainer.kt b/app/src/main/java/com/into/websoso/ui/notification/component/NotificationsContainer.kt
index 2accb4dcc..a87274c1c 100644
--- a/app/src/main/java/com/into/websoso/ui/notification/component/NotificationsContainer.kt
+++ b/app/src/main/java/com/into/websoso/ui/notification/component/NotificationsContainer.kt
@@ -22,6 +22,7 @@ fun NotificationsContainer(
updateNotifications: () -> Unit,
onNotificationDetailClick: (NotificationModel) -> Unit,
onFeedDetailClick: (NotificationModel) -> Unit,
+ onNovelDetailClick: (NotificationModel) -> Unit,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
@@ -48,6 +49,7 @@ fun NotificationsContainer(
notification = notification,
onNotificationDetailClick = onNotificationDetailClick,
onFeedDetailClick = onFeedDetailClick,
+ onNovelDetailClick = onNovelDetailClick,
)
},
)
@@ -60,11 +62,13 @@ private fun navigateToDetail(
notification: NotificationModel,
onNotificationDetailClick: (NotificationModel) -> Unit,
onFeedDetailClick: (NotificationModel) -> Unit,
+ onNovelDetailClick: (NotificationModel) -> Unit,
) {
if (notification.intrinsicId == DEFAULT_INTRINSIC_ID) return
when (notification.notificationType) {
NotificationType.NOTICE -> onNotificationDetailClick(notification)
NotificationType.FEED -> onFeedDetailClick(notification)
+ NotificationType.NOVEL -> onNovelDetailClick(notification)
NotificationType.NONE -> Unit
}
}
@@ -79,6 +83,7 @@ private fun NotificationsContainerPreview() {
updateNotifications = {},
onNotificationDetailClick = {},
onFeedDetailClick = {},
+ onNovelDetailClick = {},
)
}
}
diff --git a/app/src/main/java/com/into/websoso/ui/notificationSetting/NotificationSettingActivity.kt b/app/src/main/java/com/into/websoso/ui/notificationSetting/NotificationSettingActivity.kt
index bc37b572b..22962a90b 100644
--- a/app/src/main/java/com/into/websoso/ui/notificationSetting/NotificationSettingActivity.kt
+++ b/app/src/main/java/com/into/websoso/ui/notificationSetting/NotificationSettingActivity.kt
@@ -9,6 +9,10 @@ import com.into.websoso.R
import com.into.websoso.core.common.ui.base.BaseActivity
import com.into.websoso.core.common.util.SingleEventHandler
import com.into.websoso.databinding.ActivityNotificationSettingBinding
+import com.into.websoso.domain.model.NovelNotificationType
+import com.into.websoso.domain.model.NovelNotificationType.COMPLETION
+import com.into.websoso.domain.model.NovelNotificationType.HIATUS_RETURN
+import com.into.websoso.ui.novelNotification.NovelNotificationListActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -27,6 +31,12 @@ class NotificationSettingActivity : BaseActivity(activity_no
setupWebsosoLoadingLayout()
setupViewPager()
novelDetailViewModel.updateNovelDetail(novelId)
+ updateNotificationRead()
handleBackPressed()
tracker.trackEvent("novel_info")
}
+ private fun updateNotificationRead() {
+ val notificationId = intent.getLongExtra(NOTIFICATION_ID, DEFAULT_NOTIFICATION_ID)
+ novelDetailViewModel.updateNotificationRead(notificationId)
+ }
+
private fun bindViewModel() {
binding.novelDetailViewModel = novelDetailViewModel
binding.lifecycleOwner = this
@@ -314,6 +321,14 @@ class NovelDetailActivity : BaseActivity(activity_no
showPopupWindow()
}
+ override fun onNotificationClick() {
+ if (novelDetailViewModel.novelDetailModel.value?.isLogin == false) {
+ showLoginRequestDialog()
+ return
+ }
+ showNovelNotificationBottomSheet()
+ }
+
override fun onNavigateToNovelRatingClick(readStatus: ReadStatus) {
if (novelDetailViewModel.novelDetailModel.value?.isLogin == false) {
binding.tgNovelDetailReadStatus.clearChecked()
@@ -401,6 +416,15 @@ class NovelDetailActivity : BaseActivity(activity_no
dialog.show(supportFragmentManager, LoginRequestDialogFragment.TAG)
}
+ private fun showNovelNotificationBottomSheet() {
+ NovelNotificationBottomSheetDialog
+ .newInstance(novelId)
+ .show(
+ supportFragmentManager,
+ NovelNotificationBottomSheetDialog.NOVEL_NOTIFICATION_BOTTOM_SHEET_TAG,
+ )
+ }
+
override fun onResume() {
super.onResume()
binding.tgNovelDetailReadStatus.clearChecked()
@@ -411,15 +435,18 @@ class NovelDetailActivity : BaseActivity(activity_no
private const val INFO_FRAGMENT_PAGE = 0
private const val FEED_FRAGMENT_PAGE = 1
private const val NOVEL_ID = "NOVEL_ID"
+ private const val NOTIFICATION_ID = "NOTIFICATION_ID"
private const val POPUP_MARGIN_END = -128
private const val POPUP_MARGIN_TOP = 4
fun getIntent(
context: Context,
novelId: Long,
+ notificationId: Long = DEFAULT_NOTIFICATION_ID,
): Intent =
Intent(context, NovelDetailActivity::class.java).apply {
putExtra(NOVEL_ID, novelId)
+ putExtra(NOTIFICATION_ID, notificationId)
}
}
}
diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailClickListener.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailClickListener.kt
index 28d98445a..a45c8c7fd 100644
--- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailClickListener.kt
+++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailClickListener.kt
@@ -7,6 +7,8 @@ interface NovelDetailClickListener {
fun onShowMenuClick()
+ fun onNotificationClick()
+
fun onNavigateToNovelRatingClick(readStatus: ReadStatus)
fun onNovelCoverClick(novelImageUrl: String)
diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailViewModel.kt
index 69157732b..948b281cb 100644
--- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailViewModel.kt
+++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailViewModel.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.into.websoso.data.repository.NotificationRepository
import com.into.websoso.data.repository.NovelRepository
import com.into.websoso.data.repository.UserNovelRepository
import com.into.websoso.data.repository.UserRepository
@@ -17,6 +18,7 @@ import javax.inject.Inject
class NovelDetailViewModel
@Inject
constructor(
+ private val notificationRepository: NotificationRepository,
private val novelRepository: NovelRepository,
private val userNovelRepository: UserNovelRepository,
private val userRepository: UserRepository,
@@ -137,4 +139,15 @@ class NovelDetailViewModel
) ?: return,
)
}
+
+ fun updateNotificationRead(notificationId: Long) {
+ if (notificationId == DEFAULT_NOTIFICATION_ID) return
+ viewModelScope.launch {
+ runCatching { notificationRepository.fetchNotificationRead(notificationId) }
+ }
+ }
+
+ companion object {
+ const val DEFAULT_NOTIFICATION_ID: Long = -1
+ }
}
diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt
new file mode 100644
index 000000000..430431172
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt
@@ -0,0 +1,83 @@
+package com.into.websoso.ui.novelDetail
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.into.websoso.R
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.ui.novelDetail.component.NovelNotificationContent
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class NovelNotificationBottomSheetDialog : BottomSheetDialogFragment() {
+ private val novelNotificationViewModel: NovelNotificationViewModel by viewModels()
+ private val novelId: Long by lazy { arguments?.getLong(NOVEL_ID) ?: DEFAULT_NOVEL_ID }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.WebsosoBottomSheetTheme)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View =
+ ComposeView(requireContext()).apply {
+ layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ val uiState by novelNotificationViewModel.novelNotificationUiState.collectAsStateWithLifecycle()
+
+ WebsosoTheme {
+ NovelNotificationContent(
+ uiState = uiState,
+ onCompletionToggleClick = {
+ novelNotificationViewModel.updateCompletionNotificationEnabled(
+ novelId = novelId,
+ isEnabled = uiState.isCompletionNotificationEnabled.not(),
+ )
+ },
+ onHiatusReturnToggleClick = {
+ novelNotificationViewModel.updateHiatusReturnNotificationEnabled(
+ novelId = novelId,
+ isEnabled = uiState.isHiatusReturnNotificationEnabled.not(),
+ )
+ },
+ onRetryClick = {
+ novelNotificationViewModel.updateNovelNotificationSetting(novelId)
+ },
+ )
+ }
+ }
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ novelNotificationViewModel.updateNovelNotificationSetting(novelId)
+ }
+
+ companion object {
+ const val NOVEL_NOTIFICATION_BOTTOM_SHEET_TAG = "NovelNotificationBottomSheetDialog"
+ private const val NOVEL_ID = "NOVEL_ID"
+ private const val DEFAULT_NOVEL_ID = 0L
+
+ fun newInstance(novelId: Long): NovelNotificationBottomSheetDialog =
+ NovelNotificationBottomSheetDialog().apply {
+ arguments = Bundle().apply { putLong(NOVEL_ID, novelId) }
+ }
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt
new file mode 100644
index 000000000..04424f234
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt
@@ -0,0 +1,114 @@
+package com.into.websoso.ui.novelDetail
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.into.websoso.domain.model.NovelNotificationSetting
+import com.into.websoso.domain.usecase.GetNovelNotificationSettingUseCase
+import com.into.websoso.domain.usecase.UpdateNovelNotificationSettingUseCase
+import com.into.websoso.ui.novelDetail.model.NovelNotificationUiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class NovelNotificationViewModel
+ @Inject
+ constructor(
+ private val getNovelNotificationSettingUseCase: GetNovelNotificationSettingUseCase,
+ private val updateNovelNotificationSettingUseCase: UpdateNovelNotificationSettingUseCase,
+ ) : ViewModel() {
+ private val _novelNotificationUiState: MutableStateFlow =
+ MutableStateFlow(NovelNotificationUiState())
+ val novelNotificationUiState: StateFlow get() = _novelNotificationUiState
+
+ private var syncedNovelNotificationSetting: NovelNotificationSetting = NovelNotificationSetting()
+ private var saveJob: Job? = null
+
+ private val saveScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ fun updateNovelNotificationSetting(novelId: Long) {
+ _novelNotificationUiState.value = novelNotificationUiState.value.copy(
+ isLoading = true,
+ isError = false,
+ )
+
+ viewModelScope.launch {
+ getNovelNotificationSettingUseCase(novelId)
+ .onSuccess { novelNotificationSetting ->
+ syncedNovelNotificationSetting = novelNotificationSetting
+ _novelNotificationUiState.value = novelNotificationUiState.value.copy(
+ isLoading = false,
+ isError = false,
+ isCompletionNotificationEnabled = novelNotificationSetting.isCompletionNotificationEnabled,
+ isHiatusReturnNotificationEnabled = novelNotificationSetting.isHiatusReturnNotificationEnabled,
+ )
+ }.onFailure {
+ _novelNotificationUiState.value = novelNotificationUiState.value.copy(
+ isLoading = false,
+ isError = true,
+ )
+ }
+ }
+ }
+
+ fun updateCompletionNotificationEnabled(
+ novelId: Long,
+ isEnabled: Boolean,
+ ) {
+ if (novelNotificationUiState.value.isEditable.not()) return
+
+ _novelNotificationUiState.value = novelNotificationUiState.value.copy(
+ isCompletionNotificationEnabled = isEnabled,
+ )
+
+ saveNovelNotificationSetting(novelId)
+ }
+
+ fun updateHiatusReturnNotificationEnabled(
+ novelId: Long,
+ isEnabled: Boolean,
+ ) {
+ if (novelNotificationUiState.value.isEditable.not()) return
+
+ _novelNotificationUiState.value = novelNotificationUiState.value.copy(
+ isHiatusReturnNotificationEnabled = isEnabled,
+ )
+
+ saveNovelNotificationSetting(novelId)
+ }
+
+ private fun saveNovelNotificationSetting(novelId: Long) {
+ saveJob?.cancel()
+ saveJob = saveScope.launch {
+ delay(SAVE_DEBOUNCE_MILLIS)
+
+ val requestedNovelNotificationSetting = NovelNotificationSetting(
+ isCompletionNotificationEnabled = novelNotificationUiState.value.isCompletionNotificationEnabled,
+ isHiatusReturnNotificationEnabled = novelNotificationUiState.value.isHiatusReturnNotificationEnabled,
+ )
+
+ updateNovelNotificationSettingUseCase(
+ novelId = novelId,
+ novelNotificationSetting = requestedNovelNotificationSetting,
+ ).onSuccess {
+ syncedNovelNotificationSetting = requestedNovelNotificationSetting
+ }.onFailure {
+ _novelNotificationUiState.value = novelNotificationUiState.value.copy(
+ isCompletionNotificationEnabled = syncedNovelNotificationSetting.isCompletionNotificationEnabled,
+ isHiatusReturnNotificationEnabled = syncedNovelNotificationSetting.isHiatusReturnNotificationEnabled,
+ )
+ }
+ }
+ }
+
+ companion object {
+ private const val SAVE_DEBOUNCE_MILLIS = 300L
+ }
+ }
diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/component/NovelNotificationContent.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/component/NovelNotificationContent.kt
new file mode 100644
index 000000000..382cdd903
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelDetail/component/NovelNotificationContent.kt
@@ -0,0 +1,258 @@
+package com.into.websoso.ui.novelDetail.component
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment.Companion.CenterStart
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.common.util.clickableWithoutRipple
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Gray70
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.string.novel_notification_completion_description
+import com.into.websoso.core.resource.R.string.novel_notification_completion_title
+import com.into.websoso.core.resource.R.string.novel_notification_hiatus_return_description
+import com.into.websoso.core.resource.R.string.novel_notification_hiatus_return_title
+import com.into.websoso.core.resource.R.string.novel_notification_load_fail
+import com.into.websoso.core.resource.R.string.novel_notification_retry
+import com.into.websoso.ui.novelDetail.model.NovelNotificationUiState
+
+@Composable
+fun NovelNotificationContent(
+ uiState: NovelNotificationUiState,
+ onCompletionToggleClick: () -> Unit,
+ onHiatusReturnToggleClick: () -> Unit,
+ onRetryClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val contentAlpha = when (uiState.isError) {
+ true -> DISABLED_ALPHA
+ false -> DEFAULT_ALPHA
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(
+ color = White,
+ shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
+ ).padding(horizontal = 20.dp)
+ .padding(top = 20.dp, bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ NovelNotificationToggleItem(
+ title = stringResource(novel_notification_completion_title),
+ description = stringResource(novel_notification_completion_description),
+ isChecked = uiState.isCompletionNotificationEnabled,
+ isEditable = uiState.isEditable,
+ onClick = onCompletionToggleClick,
+ modifier = Modifier.alpha(contentAlpha),
+ )
+ NovelNotificationToggleItem(
+ title = stringResource(novel_notification_hiatus_return_title),
+ description = stringResource(novel_notification_hiatus_return_description),
+ isChecked = uiState.isHiatusReturnNotificationEnabled,
+ isEditable = uiState.isEditable,
+ onClick = onHiatusReturnToggleClick,
+ modifier = Modifier.alpha(contentAlpha),
+ )
+ if (uiState.isError) {
+ NovelNotificationRetryRow(onRetryClick = onRetryClick)
+ }
+ }
+}
+
+@Composable
+private fun NovelNotificationRetryRow(
+ onRetryClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = CenterVertically,
+ ) {
+ Text(
+ text = stringResource(novel_notification_load_fail),
+ style = WebsosoTheme.typography.body5,
+ color = Gray200,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = stringResource(novel_notification_retry),
+ style = WebsosoTheme.typography.label1,
+ color = Primary100,
+ modifier = Modifier
+ .clickableWithoutRipple { onRetryClick() }
+ .padding(vertical = 4.dp),
+ )
+ }
+}
+
+@Composable
+private fun NovelNotificationToggleItem(
+ title: String,
+ description: String,
+ isChecked: Boolean,
+ isEditable: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ enabled = isEditable,
+ onClick = onClick,
+ ).padding(vertical = 8.dp),
+ verticalAlignment = CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = title,
+ style = WebsosoTheme.typography.body2,
+ color = Black,
+ )
+ Text(
+ text = description,
+ style = WebsosoTheme.typography.body5,
+ color = Gray200,
+ )
+ }
+ NovelNotificationToggle(isChecked = isChecked)
+ }
+}
+
+@Composable
+private fun NovelNotificationToggle(
+ isChecked: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val thumbOffset by animateDpAsState(
+ targetValue = when (isChecked) {
+ true -> TRACK_WIDTH - THUMB_SIZE - THUMB_MARGIN
+ false -> THUMB_MARGIN
+ },
+ label = "NovelNotificationToggleThumbOffset",
+ )
+
+ Box(
+ modifier = modifier
+ .size(width = TRACK_WIDTH, height = TRACK_HEIGHT)
+ .clip(CircleShape)
+ .background(
+ color = when (isChecked) {
+ true -> Primary100
+ false -> Gray70
+ },
+ ),
+ ) {
+ Box(
+ modifier = Modifier
+ .align(CenterStart)
+ .offset(x = thumbOffset)
+ .size(THUMB_SIZE)
+ .clip(CircleShape)
+ .background(White),
+ )
+ }
+}
+
+private val TRACK_WIDTH = 48.dp
+private val TRACK_HEIGHT = 24.dp
+private val THUMB_SIZE = 20.dp
+private val THUMB_MARGIN = 2.dp
+private const val DEFAULT_ALPHA = 1f
+private const val DISABLED_ALPHA = 0.4f
+
+@Preview
+@Composable
+private fun NovelNotificationContentPreview() {
+ WebsosoTheme {
+ NovelNotificationContent(
+ uiState = NovelNotificationUiState(isLoading = false),
+ onCompletionToggleClick = {},
+ onHiatusReturnToggleClick = {},
+ onRetryClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationContentPartiallyEnabledPreview() {
+ WebsosoTheme {
+ NovelNotificationContent(
+ uiState = NovelNotificationUiState(
+ isLoading = false,
+ isCompletionNotificationEnabled = true,
+ ),
+ onCompletionToggleClick = {},
+ onHiatusReturnToggleClick = {},
+ onRetryClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationContentEnabledPreview() {
+ WebsosoTheme {
+ NovelNotificationContent(
+ uiState = NovelNotificationUiState(
+ isLoading = false,
+ isCompletionNotificationEnabled = true,
+ isHiatusReturnNotificationEnabled = true,
+ ),
+ onCompletionToggleClick = {},
+ onHiatusReturnToggleClick = {},
+ onRetryClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationContentErrorPreview() {
+ WebsosoTheme {
+ NovelNotificationContent(
+ uiState = NovelNotificationUiState(
+ isLoading = false,
+ isError = true,
+ ),
+ onCompletionToggleClick = {},
+ onHiatusReturnToggleClick = {},
+ onRetryClick = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiState.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiState.kt
new file mode 100644
index 000000000..346ecc86c
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiState.kt
@@ -0,0 +1,11 @@
+package com.into.websoso.ui.novelDetail.model
+
+data class NovelNotificationUiState(
+ val isLoading: Boolean = true,
+ val isError: Boolean = false,
+ val isCompletionNotificationEnabled: Boolean = false,
+ val isHiatusReturnNotificationEnabled: Boolean = false,
+) {
+ // 초기 조회 전 기본값(false)을 기준으로 저장하면 건드리지 않은 알림까지 해제되므로 조회 성공 후에만 변경을 허용한다
+ val isEditable: Boolean get() = isLoading.not() && isError.not()
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListActivity.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListActivity.kt
new file mode 100644
index 000000000..663317896
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListActivity.kt
@@ -0,0 +1,57 @@
+package com.into.websoso.ui.novelNotification
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import com.into.websoso.core.common.util.setupSystemBarIconColor
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.domain.model.NovelNotificationType
+import com.into.websoso.ui.normalExplore.NormalExploreActivity
+import com.into.websoso.ui.novelDetail.NovelDetailActivity
+import com.into.websoso.ui.novelNotification.model.NovelNotificationSubscriptionModel
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class NovelNotificationListActivity : AppCompatActivity() {
+ private val novelNotificationListViewModel: NovelNotificationListViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setupSystemBarIconColor(true)
+ setContent {
+ WebsosoTheme {
+ NovelNotificationListScreen(
+ viewModel = novelNotificationListViewModel,
+ onSubscriptionClick = ::navigateToNovelDetail,
+ onExploreClick = ::navigateToNormalExplore,
+ onBackButtonClick = ::finish,
+ )
+ }
+ }
+ }
+
+ private fun navigateToNovelDetail(subscription: NovelNotificationSubscriptionModel) {
+ startActivity(NovelDetailActivity.getIntent(this, subscription.novelId))
+ }
+
+ private fun navigateToNormalExplore() {
+ startActivity(NormalExploreActivity.getIntent(this))
+ }
+
+ companion object {
+ const val NOVEL_NOTIFICATION_TYPE = "NOVEL_NOTIFICATION_TYPE"
+
+ fun getIntent(
+ context: Context,
+ notificationType: NovelNotificationType,
+ ): Intent =
+ Intent(context, NovelNotificationListActivity::class.java).apply {
+ putExtra(NOVEL_NOTIFICATION_TYPE, notificationType.name)
+ }
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt
new file mode 100644
index 000000000..abb15dbda
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt
@@ -0,0 +1,202 @@
+package com.into.websoso.ui.novelNotification
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.ui.novelNotification.component.NovelNotificationDeleteDialog
+import com.into.websoso.ui.novelNotification.component.NovelNotificationEmptyView
+import com.into.websoso.ui.novelNotification.component.NovelNotificationErrorView
+import com.into.websoso.ui.novelNotification.component.NovelNotificationListAppBar
+import com.into.websoso.ui.novelNotification.component.NovelNotificationSubscriptionsContainer
+import com.into.websoso.ui.novelNotification.model.NovelNotificationListUiState
+import com.into.websoso.ui.novelNotification.model.NovelNotificationSubscriptionModel
+
+@Composable
+fun NovelNotificationListScreen(
+ viewModel: NovelNotificationListViewModel,
+ onSubscriptionClick: (NovelNotificationSubscriptionModel) -> Unit,
+ onExploreClick: () -> Unit,
+ onBackButtonClick: () -> Unit,
+) {
+ val uiState by viewModel.novelNotificationListUiState.collectAsStateWithLifecycle()
+
+ val onBackClick: () -> Unit = {
+ when (uiState.isEditing) {
+ true -> viewModel.updateEditing(false)
+ false -> onBackButtonClick()
+ }
+ }
+
+ BackHandler { onBackClick() }
+
+ NovelNotificationListScreen(
+ uiState = uiState,
+ updateSubscriptions = viewModel::updateSubscriptions,
+ onEditButtonClick = { viewModel.updateEditing(true) },
+ onDeleteButtonClick = { viewModel.updateDeleteDialogVisibility(true) },
+ onSubscriptionSelect = { viewModel.updateSelectedNovel(it.novelId) },
+ onDeleteDialogCancelClick = { viewModel.updateDeleteDialogVisibility(false) },
+ onDeleteDialogConfirmClick = viewModel::deleteSelectedSubscriptions,
+ onSubscriptionClick = onSubscriptionClick,
+ onExploreClick = onExploreClick,
+ onBackButtonClick = onBackClick,
+ )
+}
+
+@Composable
+private fun NovelNotificationListScreen(
+ uiState: NovelNotificationListUiState,
+ updateSubscriptions: () -> Unit,
+ onEditButtonClick: () -> Unit,
+ onDeleteButtonClick: () -> Unit,
+ onSubscriptionSelect: (NovelNotificationSubscriptionModel) -> Unit,
+ onDeleteDialogCancelClick: () -> Unit,
+ onDeleteDialogConfirmClick: () -> Unit,
+ onSubscriptionClick: (NovelNotificationSubscriptionModel) -> Unit,
+ onExploreClick: () -> Unit,
+ onBackButtonClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(White)
+ .windowInsetsPadding(WindowInsets.systemBars),
+ ) {
+ NovelNotificationListAppBar(
+ notificationType = uiState.notificationType,
+ isEditing = uiState.isEditing,
+ isDeletable = uiState.isDeletable,
+ isActionVisible = uiState.isActionVisible,
+ onBackButtonClick = onBackButtonClick,
+ onEditButtonClick = onEditButtonClick,
+ onDeleteButtonClick = onDeleteButtonClick,
+ )
+ when {
+ uiState.isErrorVisible -> NovelNotificationErrorView(onReloadClick = updateSubscriptions)
+
+ uiState.isEmpty -> NovelNotificationEmptyView(onExploreClick = onExploreClick)
+
+ else -> NovelNotificationSubscriptionsContainer(
+ subscriptions = uiState.subscriptions,
+ selectedNovelIds = uiState.selectedNovelIds,
+ isEditing = uiState.isEditing,
+ isLoadable = uiState.isLoadable,
+ updateSubscriptions = updateSubscriptions,
+ onSubscriptionClick = onSubscriptionClick,
+ onSubscriptionSelect = onSubscriptionSelect,
+ )
+ }
+ }
+
+ if (uiState.isDeleteDialogVisible) {
+ NovelNotificationDeleteDialog(
+ selectedSubscriptions = uiState.selectedSubscriptions,
+ onCancelClick = onDeleteDialogCancelClick,
+ onConfirmClick = onDeleteDialogConfirmClick,
+ )
+ }
+}
+
+private val previewSubscriptions = List(3) { index ->
+ NovelNotificationSubscriptionModel(
+ subscriptionId = index.toLong(),
+ novelId = index.toLong(),
+ novelTitle = "여주인공의 이해를 돕기 위하여 $index",
+ novelAuthor = "이보라",
+ novelImage = "",
+ registeredDate = "2026.07.04",
+ )
+}
+
+@Composable
+private fun NovelNotificationListScreenPreviewContent(uiState: NovelNotificationListUiState) {
+ WebsosoTheme {
+ NovelNotificationListScreen(
+ uiState = uiState,
+ updateSubscriptions = {},
+ onEditButtonClick = {},
+ onDeleteButtonClick = {},
+ onSubscriptionSelect = {},
+ onDeleteDialogCancelClick = {},
+ onDeleteDialogConfirmClick = {},
+ onSubscriptionClick = {},
+ onExploreClick = {},
+ onBackButtonClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListScreenPreview() {
+ NovelNotificationListScreenPreviewContent(
+ uiState = NovelNotificationListUiState(
+ isInitialLoaded = true,
+ isLoadable = false,
+ subscriptions = previewSubscriptions,
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListScreenEditingPreview() {
+ NovelNotificationListScreenPreviewContent(
+ uiState = NovelNotificationListUiState(
+ isInitialLoaded = true,
+ isLoadable = false,
+ isEditing = true,
+ selectedNovelIds = setOf(0L, 2L),
+ subscriptions = previewSubscriptions,
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListScreenEmptyPreview() {
+ NovelNotificationListScreenPreviewContent(
+ uiState = NovelNotificationListUiState(
+ isInitialLoaded = true,
+ isLoadable = false,
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListScreenErrorPreview() {
+ NovelNotificationListScreenPreviewContent(
+ uiState = NovelNotificationListUiState(
+ isInitialLoaded = true,
+ isLoadable = false,
+ isError = true,
+ ),
+ )
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListScreenDeleteDialogPreview() {
+ NovelNotificationListScreenPreviewContent(
+ uiState = NovelNotificationListUiState(
+ isInitialLoaded = true,
+ isLoadable = false,
+ isEditing = true,
+ isDeleteDialogVisible = true,
+ selectedNovelIds = setOf(0L, 2L),
+ subscriptions = previewSubscriptions,
+ ),
+ )
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt
new file mode 100644
index 000000000..b653dc77a
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt
@@ -0,0 +1,152 @@
+package com.into.websoso.ui.novelNotification
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.into.websoso.domain.model.NovelNotificationSubscriptions
+import com.into.websoso.domain.model.NovelNotificationType
+import com.into.websoso.domain.usecase.DeleteNovelNotificationSubscriptionsUseCase
+import com.into.websoso.domain.usecase.GetNovelNotificationSubscriptionsUseCase
+import com.into.websoso.ui.mapper.toUi
+import com.into.websoso.ui.novelNotification.NovelNotificationListActivity.Companion.NOVEL_NOTIFICATION_TYPE
+import com.into.websoso.ui.novelNotification.model.NovelNotificationListUiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class NovelNotificationListViewModel
+ @Inject
+ constructor(
+ savedStateHandle: SavedStateHandle,
+ private val getNovelNotificationSubscriptionsUseCase: GetNovelNotificationSubscriptionsUseCase,
+ private val deleteNovelNotificationSubscriptionsUseCase: DeleteNovelNotificationSubscriptionsUseCase,
+ ) : ViewModel() {
+ private val notificationType: NovelNotificationType = NovelNotificationType.from(
+ savedStateHandle.get(NOVEL_NOTIFICATION_TYPE).orEmpty(),
+ )
+
+ private val _novelNotificationListUiState: MutableStateFlow =
+ MutableStateFlow(NovelNotificationListUiState(notificationType = notificationType))
+ val novelNotificationListUiState: StateFlow get() = _novelNotificationListUiState
+
+ private var isRefetchPending = false
+
+ init {
+ updateSubscriptions()
+ }
+
+ fun updateSubscriptions() {
+ val currentUiState = novelNotificationListUiState.value
+ if (currentUiState.isLoadable.not() || currentUiState.isLoading) return
+
+ _novelNotificationListUiState.value = currentUiState.copy(
+ isLoading = true,
+ isError = false,
+ )
+
+ viewModelScope.launch {
+ getNovelNotificationSubscriptionsUseCase(
+ notificationType = notificationType,
+ lastSubscriptionId = currentUiState.lastSubscriptionId,
+ ).onSuccess { novelNotificationSubscriptions ->
+ handleSuccessState(novelNotificationSubscriptions)
+ }.onFailure {
+ handleFailureState()
+ }
+ }
+ }
+
+ private fun handleSuccessState(novelNotificationSubscriptions: NovelNotificationSubscriptions) {
+ val currentUiState = novelNotificationListUiState.value
+ _novelNotificationListUiState.value = currentUiState.copy(
+ isLoadable = novelNotificationSubscriptions.isLoadable,
+ isLoading = false,
+ isError = false,
+ isInitialLoaded = true,
+ lastSubscriptionId = novelNotificationSubscriptions.nextSubscriptionId,
+ subscriptions = currentUiState.subscriptions +
+ novelNotificationSubscriptions.subscriptions.map { it.toUi() },
+ )
+
+ consumeRefetchPending()
+ }
+
+ private fun consumeRefetchPending() {
+ if (isRefetchPending.not()) return
+ isRefetchPending = false
+
+ if (novelNotificationListUiState.value.subscriptions.isEmpty()) updateSubscriptions()
+ }
+
+ private fun handleFailureState() {
+ isRefetchPending = false
+
+ _novelNotificationListUiState.value = novelNotificationListUiState.value.copy(
+ isLoading = false,
+ isError = true,
+ isInitialLoaded = true,
+ )
+ }
+
+ fun updateEditing(isEditing: Boolean) {
+ _novelNotificationListUiState.value = novelNotificationListUiState.value.copy(
+ isEditing = isEditing,
+ selectedNovelIds = emptySet(),
+ )
+ }
+
+ fun updateSelectedNovel(novelId: Long) {
+ val currentUiState = novelNotificationListUiState.value
+ val selectedNovelIds = when (novelId in currentUiState.selectedNovelIds) {
+ true -> currentUiState.selectedNovelIds - novelId
+ false -> currentUiState.selectedNovelIds + novelId
+ }
+
+ _novelNotificationListUiState.value = currentUiState.copy(selectedNovelIds = selectedNovelIds)
+ }
+
+ fun updateDeleteDialogVisibility(isVisible: Boolean) {
+ _novelNotificationListUiState.value = novelNotificationListUiState.value.copy(
+ isDeleteDialogVisible = isVisible,
+ )
+ }
+
+ fun deleteSelectedSubscriptions() {
+ val selectedNovelIds = novelNotificationListUiState.value.selectedNovelIds
+ if (selectedNovelIds.isEmpty()) return
+
+ viewModelScope.launch {
+ deleteNovelNotificationSubscriptionsUseCase(
+ notificationType = notificationType,
+ novelIds = selectedNovelIds.toList(),
+ ).onSuccess { novelNotificationDeleteResult ->
+ handleDeleteSuccessState(novelNotificationDeleteResult.deletedNovelIds.toSet())
+ }.onFailure {
+ updateDeleteDialogVisibility(false)
+ }
+ }
+ }
+
+ private fun handleDeleteSuccessState(deletedNovelIds: Set) {
+ val currentUiState = novelNotificationListUiState.value
+ val remainedSubscriptions = currentUiState.subscriptions.filterNot { it.novelId in deletedNovelIds }
+
+ _novelNotificationListUiState.value = currentUiState.copy(
+ isEditing = false,
+ isDeleteDialogVisible = false,
+ selectedNovelIds = emptySet(),
+ subscriptions = remainedSubscriptions,
+ )
+
+ // 로드된 항목을 모두 삭제해도 다음 페이지가 남아 있으면 빈 화면 대신 이어서 불러온다
+ if (remainedSubscriptions.isEmpty() && currentUiState.isLoadable) {
+ when (currentUiState.isLoading) {
+ true -> isRefetchPending = true
+ false -> updateSubscriptions()
+ }
+ }
+ }
+ }
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationTypeExtensions.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationTypeExtensions.kt
new file mode 100644
index 000000000..b04e23d64
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationTypeExtensions.kt
@@ -0,0 +1,15 @@
+package com.into.websoso.ui.novelNotification
+
+import androidx.annotation.StringRes
+import com.into.websoso.core.resource.R.string.novel_notification_completion_title
+import com.into.websoso.core.resource.R.string.novel_notification_hiatus_return_title
+import com.into.websoso.domain.model.NovelNotificationType
+import com.into.websoso.domain.model.NovelNotificationType.COMPLETION
+import com.into.websoso.domain.model.NovelNotificationType.HIATUS_RETURN
+
+@StringRes
+fun NovelNotificationType.novelNotificationTitleRes(): Int =
+ when (this) {
+ COMPLETION -> novel_notification_completion_title
+ HIATUS_RETURN -> novel_notification_hiatus_return_title
+ }
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationDeleteDialog.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationDeleteDialog.kt
new file mode 100644
index 000000000..9dbc160bb
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationDeleteDialog.kt
@@ -0,0 +1,169 @@
+package com.into.websoso.ui.novelNotification.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign.Companion.Center
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.into.websoso.core.common.util.clickableWithoutRipple
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray300
+import com.into.websoso.core.designsystem.theme.Gray50
+import com.into.websoso.core.designsystem.theme.Secondary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.string.novel_notification_delete_dialog_cancel
+import com.into.websoso.core.resource.R.string.novel_notification_delete_dialog_confirm
+import com.into.websoso.core.resource.R.string.novel_notification_delete_dialog_message_multiple
+import com.into.websoso.core.resource.R.string.novel_notification_delete_dialog_message_single
+import com.into.websoso.core.resource.R.string.novel_notification_delete_dialog_title
+import com.into.websoso.ui.novelNotification.model.NovelNotificationSubscriptionModel
+
+@Composable
+fun NovelNotificationDeleteDialog(
+ selectedSubscriptions: List,
+ onCancelClick: () -> Unit,
+ onConfirmClick: () -> Unit,
+) {
+ val firstSelectedSubscription = selectedSubscriptions.firstOrNull() ?: return
+
+ Dialog(onDismissRequest = onCancelClick) {
+ Column(
+ modifier = Modifier
+ .background(color = White, shape = RoundedCornerShape(14.dp))
+ .padding(horizontal = 22.dp, vertical = 24.dp),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(novel_notification_delete_dialog_title),
+ style = WebsosoTheme.typography.title1,
+ color = Black,
+ textAlign = Center,
+ )
+ Text(
+ text = when (selectedSubscriptions.size) {
+ 1 -> stringResource(
+ novel_notification_delete_dialog_message_single,
+ firstSelectedSubscription.novelTitle,
+ )
+
+ else -> stringResource(
+ novel_notification_delete_dialog_message_multiple,
+ firstSelectedSubscription.novelTitle,
+ selectedSubscriptions.size - 1,
+ )
+ },
+ style = WebsosoTheme.typography.body2,
+ color = Gray300,
+ textAlign = Center,
+ modifier = Modifier.padding(top = 10.dp),
+ )
+ Row(
+ modifier = Modifier.padding(top = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ NovelNotificationDeleteDialogButton(
+ text = stringResource(novel_notification_delete_dialog_cancel),
+ textColor = Gray300,
+ backgroundColor = Gray50,
+ onClick = onCancelClick,
+ )
+ NovelNotificationDeleteDialogButton(
+ text = stringResource(novel_notification_delete_dialog_confirm),
+ textColor = White,
+ backgroundColor = Secondary100,
+ onClick = onConfirmClick,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NovelNotificationDeleteDialogButton(
+ text: String,
+ textColor: Color,
+ backgroundColor: Color,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .width(116.dp)
+ .height(40.dp)
+ .background(color = backgroundColor, shape = RoundedCornerShape(8.dp))
+ .clickableWithoutRipple { onClick() },
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = CenterVertically,
+ ) {
+ Text(
+ text = text,
+ style = WebsosoTheme.typography.body2,
+ color = textColor,
+ )
+ }
+}
+
+private val previewSubscriptions = listOf(
+ NovelNotificationSubscriptionModel(
+ subscriptionId = 1,
+ novelId = 1,
+ novelTitle = "여주인공의 이해를 돕기 위하여",
+ novelAuthor = "이보라",
+ novelImage = "",
+ registeredDate = "2026.07.04",
+ ),
+ NovelNotificationSubscriptionModel(
+ subscriptionId = 2,
+ novelId = 2,
+ novelTitle = "황후가 되고 싶은 여자",
+ novelAuthor = "에갈",
+ novelImage = "",
+ registeredDate = "2026.07.05",
+ ),
+ NovelNotificationSubscriptionModel(
+ subscriptionId = 3,
+ novelId = 3,
+ novelTitle = "폭렬기사 반",
+ novelAuthor = "김폭렬",
+ novelImage = "",
+ registeredDate = "2026.07.06",
+ ),
+)
+
+@Preview
+@Composable
+private fun NovelNotificationDeleteDialogPreview() {
+ WebsosoTheme {
+ NovelNotificationDeleteDialog(
+ selectedSubscriptions = previewSubscriptions.take(1),
+ onCancelClick = {},
+ onConfirmClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationDeleteDialogMultiplePreview() {
+ WebsosoTheme {
+ NovelNotificationDeleteDialog(
+ selectedSubscriptions = previewSubscriptions,
+ onCancelClick = {},
+ onConfirmClick = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt
new file mode 100644
index 000000000..f8aa08f59
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt
@@ -0,0 +1,77 @@
+package com.into.websoso.ui.novelNotification.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.Primary30
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.resource.R.drawable.ic_storage_null
+import com.into.websoso.core.resource.R.string.novel_notification_empty
+import com.into.websoso.core.resource.R.string.novel_notification_empty_explore
+
+@Composable
+fun NovelNotificationEmptyView(
+ onExploreClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ Image(
+ imageVector = ImageVector.vectorResource(id = ic_storage_null),
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(novel_notification_empty),
+ style = WebsosoTheme.typography.body1,
+ color = Gray200,
+ )
+ Spacer(modifier = Modifier.height(45.dp))
+ Button(
+ onClick = onExploreClick,
+ modifier = Modifier.fillMaxWidth(0.5f),
+ shape = RoundedCornerShape(12.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Primary30),
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 18.dp),
+ elevation = null,
+ ) {
+ Text(
+ text = stringResource(novel_notification_empty_explore),
+ style = WebsosoTheme.typography.title2,
+ color = Primary100,
+ )
+ }
+ Spacer(modifier = Modifier.weight(2f))
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationEmptyViewPreview() {
+ WebsosoTheme {
+ NovelNotificationEmptyView(onExploreClick = {})
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationErrorView.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationErrorView.kt
new file mode 100644
index 000000000..24a8ba477
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationErrorView.kt
@@ -0,0 +1,82 @@
+package com.into.websoso.ui.novelNotification.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign.Companion.Center
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.common.util.clickableWithoutRipple
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray300
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.img_load_fail
+import com.into.websoso.core.resource.R.string.load_fail_description
+import com.into.websoso.core.resource.R.string.load_fail_reload
+import com.into.websoso.core.resource.R.string.load_fail_title
+
+@Composable
+fun NovelNotificationErrorView(
+ onReloadClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ Image(
+ painter = painterResource(id = img_load_fail),
+ contentDescription = null,
+ modifier = Modifier.size(width = 166.dp, height = 160.dp),
+ )
+ Spacer(modifier = Modifier.height(40.dp))
+ Text(
+ text = stringResource(load_fail_title),
+ style = WebsosoTheme.typography.title1,
+ color = Black,
+ textAlign = Center,
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = stringResource(load_fail_description),
+ style = WebsosoTheme.typography.body2,
+ color = Gray300,
+ textAlign = Center,
+ )
+ Spacer(modifier = Modifier.height(40.dp))
+ Text(
+ text = stringResource(load_fail_reload),
+ style = WebsosoTheme.typography.label1,
+ color = White,
+ textAlign = Center,
+ modifier = Modifier
+ .background(color = Primary100, shape = RoundedCornerShape(8.dp))
+ .clickableWithoutRipple { onReloadClick() }
+ .padding(horizontal = 38.dp, vertical = 14.dp),
+ )
+ Spacer(modifier = Modifier.weight(2f))
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationErrorViewPreview() {
+ WebsosoTheme {
+ NovelNotificationErrorView(onReloadClick = {})
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt
new file mode 100644
index 000000000..22008f359
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt
@@ -0,0 +1,169 @@
+package com.into.websoso.ui.novelNotification.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale.Companion.FillHeight
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign.Companion.Center
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.common.util.clickableWithoutRipple
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Gray300
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.ic_notification_back
+import com.into.websoso.core.resource.R.string.novel_notification_delete
+import com.into.websoso.core.resource.R.string.novel_notification_edit
+import com.into.websoso.domain.model.NovelNotificationType
+import com.into.websoso.domain.model.NovelNotificationType.COMPLETION
+import com.into.websoso.domain.model.NovelNotificationType.HIATUS_RETURN
+import com.into.websoso.ui.novelNotification.novelNotificationTitleRes
+
+@Composable
+fun NovelNotificationListAppBar(
+ notificationType: NovelNotificationType,
+ isEditing: Boolean,
+ isDeletable: Boolean,
+ isActionVisible: Boolean,
+ onBackButtonClick: () -> Unit,
+ onEditButtonClick: () -> Unit,
+ onDeleteButtonClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .background(White)
+ .fillMaxWidth()
+ .padding(start = 6.dp, end = 20.dp)
+ .height(44.dp),
+ verticalAlignment = CenterVertically,
+ ) {
+ Image(
+ painter = painterResource(id = ic_notification_back),
+ contentDescription = null,
+ contentScale = FillHeight,
+ modifier = Modifier
+ .size(44.dp)
+ .clickableWithoutRipple { onBackButtonClick() },
+ )
+ Text(
+ text = stringResource(notificationType.novelNotificationTitleRes()),
+ style = WebsosoTheme.typography.title2,
+ color = Black,
+ textAlign = Center,
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 24.dp),
+ )
+ if (isActionVisible) {
+ NovelNotificationListAppBarAction(
+ isEditing = isEditing,
+ isDeletable = isDeletable,
+ onEditButtonClick = onEditButtonClick,
+ onDeleteButtonClick = onDeleteButtonClick,
+ )
+ }
+ }
+}
+
+@Composable
+private fun NovelNotificationListAppBarAction(
+ isEditing: Boolean,
+ isDeletable: Boolean,
+ onEditButtonClick: () -> Unit,
+ onDeleteButtonClick: () -> Unit,
+) {
+ when (isEditing) {
+ true -> Text(
+ text = stringResource(novel_notification_delete),
+ style = WebsosoTheme.typography.title2,
+ color = if (isDeletable) Gray300 else Gray200,
+ modifier = Modifier.clickableWithoutRipple {
+ if (isDeletable) onDeleteButtonClick()
+ },
+ )
+
+ false -> Text(
+ text = stringResource(novel_notification_edit),
+ style = WebsosoTheme.typography.title2,
+ color = Gray300,
+ modifier = Modifier.clickableWithoutRipple { onEditButtonClick() },
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListAppBarPreview() {
+ WebsosoTheme {
+ NovelNotificationListAppBar(
+ notificationType = COMPLETION,
+ isEditing = false,
+ isDeletable = false,
+ isActionVisible = true,
+ onBackButtonClick = {},
+ onEditButtonClick = {},
+ onDeleteButtonClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListAppBarEditingPreview() {
+ WebsosoTheme {
+ NovelNotificationListAppBar(
+ notificationType = COMPLETION,
+ isEditing = true,
+ isDeletable = false,
+ isActionVisible = true,
+ onBackButtonClick = {},
+ onEditButtonClick = {},
+ onDeleteButtonClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListAppBarDeletablePreview() {
+ WebsosoTheme {
+ NovelNotificationListAppBar(
+ notificationType = COMPLETION,
+ isEditing = true,
+ isDeletable = true,
+ isActionVisible = true,
+ onBackButtonClick = {},
+ onEditButtonClick = {},
+ onDeleteButtonClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationListAppBarActionHiddenPreview() {
+ WebsosoTheme {
+ NovelNotificationListAppBar(
+ notificationType = HIATUS_RETURN,
+ isEditing = false,
+ isDeletable = false,
+ isActionVisible = false,
+ onBackButtonClick = {},
+ onEditButtonClick = {},
+ onDeleteButtonClick = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionItem.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionItem.kt
new file mode 100644
index 000000000..74b08abd6
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionItem.kt
@@ -0,0 +1,161 @@
+package com.into.websoso.ui.novelNotification.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale.Companion.Crop
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.resource.R.drawable.ic_novel_notification_selected
+import com.into.websoso.core.resource.R.drawable.ic_novel_notification_unselected
+import com.into.websoso.core.resource.R.string.novel_notification_registered_date
+import com.into.websoso.ui.novelNotification.model.NovelNotificationSubscriptionModel
+
+@Composable
+fun NovelNotificationSubscriptionItem(
+ subscription: NovelNotificationSubscriptionModel,
+ isEditing: Boolean,
+ isSelected: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(18.dp),
+ verticalAlignment = CenterVertically,
+ ) {
+ AsyncImage(
+ model = subscription.novelImage,
+ contentDescription = null,
+ contentScale = Crop,
+ modifier = Modifier
+ .width(78.dp)
+ .height(105.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = subscription.novelTitle,
+ style = WebsosoTheme.typography.title3,
+ color = Black,
+ maxLines = 1,
+ overflow = Ellipsis,
+ )
+ Text(
+ text = subscription.novelAuthor,
+ style = WebsosoTheme.typography.body5Secondary,
+ color = Gray200,
+ maxLines = 1,
+ overflow = Ellipsis,
+ )
+ Text(
+ text = stringResource(
+ novel_notification_registered_date,
+ subscription.registeredDate,
+ ),
+ style = WebsosoTheme.typography.body5,
+ color = Gray200,
+ maxLines = 1,
+ )
+ }
+ when (isEditing) {
+ true -> Image(
+ imageVector = ImageVector.vectorResource(
+ id = when (isSelected) {
+ true -> ic_novel_notification_selected
+ false -> ic_novel_notification_unselected
+ },
+ ),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+
+ false -> Spacer(modifier = Modifier.size(24.dp))
+ }
+ }
+}
+
+private val previewSubscription = NovelNotificationSubscriptionModel(
+ subscriptionId = 1,
+ novelId = 1,
+ novelTitle = "여주인공의 이해를 돕기 위하여",
+ novelAuthor = "이보라",
+ novelImage = "",
+ registeredDate = "2026.07.04",
+)
+
+@Preview
+@Composable
+private fun NovelNotificationSubscriptionItemPreview() {
+ WebsosoTheme {
+ NovelNotificationSubscriptionItem(
+ subscription = previewSubscription,
+ isEditing = false,
+ isSelected = false,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationSubscriptionItemEditingPreview() {
+ WebsosoTheme {
+ NovelNotificationSubscriptionItem(
+ subscription = previewSubscription,
+ isEditing = true,
+ isSelected = false,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationSubscriptionItemSelectedPreview() {
+ WebsosoTheme {
+ NovelNotificationSubscriptionItem(
+ subscription = previewSubscription,
+ isEditing = true,
+ isSelected = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationSubscriptionItemLongTextPreview() {
+ WebsosoTheme {
+ NovelNotificationSubscriptionItem(
+ subscription = previewSubscription.copy(
+ novelTitle = "책 속 악녀가 되었는데 남주인공이 자꾸만 나를 따라다닌다",
+ novelAuthor = "아주 긴 필명을 가진 작가 이름입니다",
+ ),
+ isEditing = false,
+ isSelected = false,
+ )
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionsContainer.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionsContainer.kt
new file mode 100644
index 000000000..072bc76cf
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionsContainer.kt
@@ -0,0 +1,106 @@
+package com.into.websoso.ui.novelNotification.component
+
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.into.websoso.core.common.util.clickableWithoutRipple
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.ui.novelNotification.model.NovelNotificationSubscriptionModel
+
+private const val LOAD_THRESHOLD = 5
+
+@Composable
+fun NovelNotificationSubscriptionsContainer(
+ subscriptions: List,
+ selectedNovelIds: Set,
+ isEditing: Boolean,
+ isLoadable: Boolean,
+ updateSubscriptions: () -> Unit,
+ onSubscriptionClick: (NovelNotificationSubscriptionModel) -> Unit,
+ onSubscriptionSelect: (NovelNotificationSubscriptionModel) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(listState, isLoadable, subscriptions.size) {
+ snapshotFlow {
+ listState.layoutInfo.visibleItemsInfo
+ .lastOrNull()
+ ?.index ?: 0
+ }.collect { index ->
+ if (index + LOAD_THRESHOLD >= subscriptions.size && isLoadable) {
+ updateSubscriptions()
+ }
+ }
+ }
+
+ LazyColumn(
+ state = listState,
+ modifier = modifier,
+ ) {
+ items(
+ items = subscriptions,
+ key = { it.subscriptionId },
+ ) { subscription ->
+ NovelNotificationSubscriptionItem(
+ subscription = subscription,
+ isEditing = isEditing,
+ isSelected = subscription.novelId in selectedNovelIds,
+ modifier = Modifier.clickableWithoutRipple {
+ when (isEditing) {
+ true -> onSubscriptionSelect(subscription)
+ false -> onSubscriptionClick(subscription)
+ }
+ },
+ )
+ }
+ }
+}
+
+private val previewSubscriptions = List(3) { index ->
+ NovelNotificationSubscriptionModel(
+ subscriptionId = index.toLong(),
+ novelId = index.toLong(),
+ novelTitle = "여주인공의 이해를 돕기 위하여 $index",
+ novelAuthor = "이보라",
+ novelImage = "",
+ registeredDate = "2026.07.04",
+ )
+}
+
+@Preview
+@Composable
+private fun NovelNotificationSubscriptionsContainerPreview() {
+ WebsosoTheme {
+ NovelNotificationSubscriptionsContainer(
+ subscriptions = previewSubscriptions,
+ selectedNovelIds = emptySet(),
+ isEditing = false,
+ isLoadable = false,
+ updateSubscriptions = {},
+ onSubscriptionClick = {},
+ onSubscriptionSelect = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NovelNotificationSubscriptionsContainerEditingPreview() {
+ WebsosoTheme {
+ NovelNotificationSubscriptionsContainer(
+ subscriptions = previewSubscriptions,
+ selectedNovelIds = setOf(0L, 2L),
+ isEditing = true,
+ isLoadable = false,
+ updateSubscriptions = {},
+ onSubscriptionClick = {},
+ onSubscriptionSelect = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiState.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiState.kt
new file mode 100644
index 000000000..37bd2512a
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiState.kt
@@ -0,0 +1,32 @@
+package com.into.websoso.ui.novelNotification.model
+
+import com.into.websoso.domain.model.NovelNotificationSubscriptions.Companion.DEFAULT_SUBSCRIPTION_ID
+import com.into.websoso.domain.model.NovelNotificationType
+import com.into.websoso.domain.model.NovelNotificationType.COMPLETION
+
+data class NovelNotificationListUiState(
+ val notificationType: NovelNotificationType = COMPLETION,
+ val isLoadable: Boolean = true,
+ val isLoading: Boolean = false,
+ val isError: Boolean = false,
+ val isInitialLoaded: Boolean = false,
+ val isEditing: Boolean = false,
+ val isDeleteDialogVisible: Boolean = false,
+ val lastSubscriptionId: Long = DEFAULT_SUBSCRIPTION_ID,
+ val selectedNovelIds: Set = emptySet(),
+ val subscriptions: List = emptyList(),
+) {
+ val isEmpty: Boolean get() = isInitialLoaded && isLoading.not() && isError.not() && subscriptions.isEmpty()
+
+ val isErrorVisible: Boolean get() = isError && subscriptions.isEmpty()
+
+ val isActionVisible: Boolean get() = subscriptions.isNotEmpty()
+
+ val isDeletable: Boolean get() = selectedNovelIds.isNotEmpty()
+
+ // 삭제 알럿은 '처음 선택한 작품'을 기준으로 문구를 구성하므로 목록 순서가 아닌 선택 순서를 유지한다
+ val selectedSubscriptions: List
+ get() = selectedNovelIds.mapNotNull { novelId ->
+ subscriptions.find { it.novelId == novelId }
+ }
+}
diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationSubscriptionModel.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationSubscriptionModel.kt
new file mode 100644
index 000000000..32c89eae2
--- /dev/null
+++ b/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationSubscriptionModel.kt
@@ -0,0 +1,10 @@
+package com.into.websoso.ui.novelNotification.model
+
+data class NovelNotificationSubscriptionModel(
+ val subscriptionId: Long,
+ val novelId: Long,
+ val novelTitle: String,
+ val novelAuthor: String,
+ val novelImage: String,
+ val registeredDate: String,
+)
diff --git a/app/src/main/res/layout/activity_notification_setting.xml b/app/src/main/res/layout/activity_notification_setting.xml
index a14e3edc8..7788149b4 100644
--- a/app/src/main/res/layout/activity_notification_setting.xml
+++ b/app/src/main/res/layout/activity_notification_setting.xml
@@ -11,6 +11,14 @@
+
+
+
+
@@ -61,13 +68,16 @@
android:text="@string/notification_setting_button_title"
android:textAppearance="@style/body2"
android:textColor="@color/black"
+ app:layout_constraintBottom_toTopOf="@id/tv_notification_setting_button_description"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintVertical_chainStyle="packed" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_novel_detail.xml b/app/src/main/res/layout/activity_novel_detail.xml
index 9c9015a23..138a22fc1 100644
--- a/app/src/main/res/layout/activity_novel_detail.xml
+++ b/app/src/main/res/layout/activity_novel_detail.xml
@@ -569,13 +569,27 @@
android:layout_height="38dp"
android:layout_marginEnd="12dp"
android:onClick="@{() -> onClick.onShowMenuClick()}"
- android:padding="10dp"
- android:src="@drawable/ic_novel_detail_menu"
+ android:padding="9dp"
+ android:src="@drawable/ic_novel_detail_kebab"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/black" />
+
+
= (1L..size.toLong()).toList()
+
+ private class FakeNovelNotificationApi(
+ private val failFromCall: Int?,
+ ) : NovelNotificationApi {
+ var deleteCallCount: Int = 0
+ private set
+
+ override suspend fun deleteNovelNotificationSubscriptions(
+ novelNotificationSubscriptionsDeleteRequestDto: NovelNotificationSubscriptionsDeleteRequestDto,
+ ) {
+ deleteCallCount++
+ if (failFromCall != null && deleteCallCount >= failFromCall) throw IOException()
+ }
+
+ override suspend fun getNovelNotificationSetting(novelId: Long): NovelNotificationSettingResponseDto = throw NotImplementedError()
+
+ override suspend fun putNovelNotificationSetting(
+ novelId: Long,
+ novelNotificationSettingRequestDto: NovelNotificationSettingRequestDto,
+ ) = throw NotImplementedError()
+
+ override suspend fun getNovelNotificationSubscriptions(
+ notificationType: String,
+ lastSubscriptionId: Long,
+ size: Int,
+ ): NovelNotificationSubscriptionsResponseDto = throw NotImplementedError()
+ }
+}
diff --git a/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt b/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt
new file mode 100644
index 000000000..a9ddd11b5
--- /dev/null
+++ b/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt
@@ -0,0 +1,77 @@
+package com.into.websoso.domain.usecase
+
+import com.into.websoso.data.remote.api.NovelNotificationApi
+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 com.into.websoso.data.repository.NovelNotificationRepository
+import com.into.websoso.domain.model.NovelNotificationSetting
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.IOException
+import kotlin.coroutines.cancellation.CancellationException
+
+class UpdateNovelNotificationSettingUseCaseTest {
+ @Test
+ fun `저장이 취소되면 CancellationException을 그대로 전파한다`() {
+ val useCase = UpdateNovelNotificationSettingUseCase(
+ NovelNotificationRepository(FakeNovelNotificationApi(CancellationException())),
+ )
+
+ assertThrows(CancellationException::class.java) {
+ runBlocking { useCase(NOVEL_ID, NovelNotificationSetting()) }
+ }
+ }
+
+ @Test
+ fun `저장이 실패하면 Result failure를 반환한다`() {
+ val useCase = UpdateNovelNotificationSettingUseCase(
+ NovelNotificationRepository(FakeNovelNotificationApi(IOException())),
+ )
+
+ val result = runBlocking { useCase(NOVEL_ID, NovelNotificationSetting()) }
+
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `저장에 성공하면 Result success를 반환한다`() {
+ val useCase = UpdateNovelNotificationSettingUseCase(
+ NovelNotificationRepository(FakeNovelNotificationApi(null)),
+ )
+
+ val result = runBlocking { useCase(NOVEL_ID, NovelNotificationSetting()) }
+
+ assertTrue(result.isSuccess)
+ }
+
+ private class FakeNovelNotificationApi(
+ private val throwable: Throwable?,
+ ) : NovelNotificationApi {
+ override suspend fun getNovelNotificationSetting(novelId: Long): NovelNotificationSettingResponseDto = throw NotImplementedError()
+
+ override suspend fun putNovelNotificationSetting(
+ novelId: Long,
+ novelNotificationSettingRequestDto: NovelNotificationSettingRequestDto,
+ ) {
+ throwable?.let { throw it }
+ }
+
+ override suspend fun getNovelNotificationSubscriptions(
+ notificationType: String,
+ lastSubscriptionId: Long,
+ size: Int,
+ ): NovelNotificationSubscriptionsResponseDto = throw NotImplementedError()
+
+ override suspend fun deleteNovelNotificationSubscriptions(
+ novelNotificationSubscriptionsDeleteRequestDto: NovelNotificationSubscriptionsDeleteRequestDto,
+ ) = throw NotImplementedError()
+ }
+
+ companion object {
+ private const val NOVEL_ID = 1L
+ }
+}
diff --git a/app/src/test/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiStateTest.kt b/app/src/test/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiStateTest.kt
new file mode 100644
index 000000000..fd084c8fd
--- /dev/null
+++ b/app/src/test/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiStateTest.kt
@@ -0,0 +1,28 @@
+package com.into.websoso.ui.novelDetail.model
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NovelNotificationUiStateTest {
+ @Test
+ fun `초기 조회 중에는 변경할 수 없다`() {
+ val uiState = NovelNotificationUiState(isLoading = true)
+
+ assertFalse(uiState.isEditable)
+ }
+
+ @Test
+ fun `초기 조회에 실패하면 변경할 수 없다`() {
+ val uiState = NovelNotificationUiState(isLoading = false, isError = true)
+
+ assertFalse(uiState.isEditable)
+ }
+
+ @Test
+ fun `초기 조회에 성공해야 변경할 수 있다`() {
+ val uiState = NovelNotificationUiState(isLoading = false, isError = false)
+
+ assertTrue(uiState.isEditable)
+ }
+}
diff --git a/app/src/test/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiStateTest.kt b/app/src/test/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiStateTest.kt
new file mode 100644
index 000000000..ca53c9287
--- /dev/null
+++ b/app/src/test/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiStateTest.kt
@@ -0,0 +1,85 @@
+package com.into.websoso.ui.novelNotification.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NovelNotificationListUiStateTest {
+ @Test
+ fun `초기 조회 전에는 빈 화면으로 보지 않는다`() {
+ val uiState = NovelNotificationListUiState(isInitialLoaded = false)
+
+ assertFalse(uiState.isEmpty)
+ }
+
+ @Test
+ fun `초기 조회 후 목록이 비어야 빈 화면이다`() {
+ val uiState = NovelNotificationListUiState(isInitialLoaded = true)
+
+ assertTrue(uiState.isEmpty)
+ }
+
+ @Test
+ fun `조회에 실패하면 빈 화면이 아니라 오류 화면으로 본다`() {
+ val uiState = NovelNotificationListUiState(isInitialLoaded = true, isError = true)
+
+ assertFalse(uiState.isEmpty)
+ assertTrue(uiState.isErrorVisible)
+ }
+
+ @Test
+ fun `재시도 중에는 빈 화면으로 보지 않는다`() {
+ val uiState = NovelNotificationListUiState(isInitialLoaded = true, isLoading = true)
+
+ assertFalse(uiState.isEmpty)
+ }
+
+ @Test
+ fun `이미 불러온 항목이 있으면 다음 페이지가 실패해도 오류 화면으로 덮지 않는다`() {
+ val uiState = NovelNotificationListUiState(
+ isInitialLoaded = true,
+ isError = true,
+ subscriptions = listOf(subscription(1)),
+ )
+
+ assertFalse(uiState.isErrorVisible)
+ }
+
+ @Test
+ fun `목록이 비어 있으면 앱바 액션을 노출하지 않는다`() {
+ assertFalse(NovelNotificationListUiState(isInitialLoaded = true).isActionVisible)
+ assertFalse(NovelNotificationListUiState(isInitialLoaded = true, isError = true).isActionVisible)
+ assertTrue(NovelNotificationListUiState(subscriptions = listOf(subscription(1))).isActionVisible)
+ }
+
+ @Test
+ fun `선택한 작품이 없으면 삭제할 수 없다`() {
+ val uiState = NovelNotificationListUiState(subscriptions = listOf(subscription(1)))
+
+ assertFalse(uiState.isDeletable)
+ }
+
+ @Test
+ fun `삭제 알럿은 목록 순서가 아닌 선택 순서의 첫 작품을 사용한다`() {
+ val uiState = NovelNotificationListUiState(
+ subscriptions = listOf(subscription(1), subscription(2), subscription(3)),
+ // 목록상 세 번째 작품을 가장 먼저 선택했다
+ selectedNovelIds = linkedSetOf(3L, 1L),
+ )
+
+ assertTrue(uiState.isDeletable)
+ assertEquals(2, uiState.selectedSubscriptions.size)
+ assertEquals("작품 3", uiState.selectedSubscriptions.first().novelTitle)
+ }
+
+ private fun subscription(novelId: Long): NovelNotificationSubscriptionModel =
+ NovelNotificationSubscriptionModel(
+ subscriptionId = novelId,
+ novelId = novelId,
+ novelTitle = "작품 $novelId",
+ novelAuthor = "작가",
+ novelImage = "",
+ registeredDate = "2026.08.27",
+ )
+}
diff --git a/core/resource/src/main/res/drawable/ic_novel_detail_kebab.xml b/core/resource/src/main/res/drawable/ic_novel_detail_kebab.xml
new file mode 100644
index 000000000..7d615b34f
--- /dev/null
+++ b/core/resource/src/main/res/drawable/ic_novel_detail_kebab.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/core/resource/src/main/res/drawable/ic_novel_detail_notification.xml b/core/resource/src/main/res/drawable/ic_novel_detail_notification.xml
new file mode 100644
index 000000000..cc4c92afe
--- /dev/null
+++ b/core/resource/src/main/res/drawable/ic_novel_detail_notification.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/core/resource/src/main/res/drawable/ic_novel_notification_selected.xml b/core/resource/src/main/res/drawable/ic_novel_notification_selected.xml
new file mode 100644
index 000000000..1de5abc61
--- /dev/null
+++ b/core/resource/src/main/res/drawable/ic_novel_notification_selected.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/core/resource/src/main/res/drawable/ic_novel_notification_unselected.xml b/core/resource/src/main/res/drawable/ic_novel_notification_unselected.xml
new file mode 100644
index 000000000..23ab69965
--- /dev/null
+++ b/core/resource/src/main/res/drawable/ic_novel_notification_unselected.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml
index 6348fcb83..7a4d78bc7 100644
--- a/core/resource/src/main/res/values/strings.xml
+++ b/core/resource/src/main/res/values/strings.xml
@@ -296,6 +296,33 @@
활동 알림
댓글, 좋아요 등 알림
+ 완결 알림
+ 작품이 완결나면 알림을 드려요
+ 휴재 복귀, 외전 알림
+ 휴재가 끝나거나 새로운 회차가 생기면 알림을 드려요
+
+
+ 작품 알림 설정
+ 완결 알림
+ 작품이 완결 나면 알림을 드려요
+ 휴재 복귀 알림
+ 새로운 회차가 생기면 알림을 드려요
+ %1$s에 알림 등록
+ 알림 등록한 작품이 없어요
+ 작품 둘러보기
+ 수정
+ 삭제
+ 해당 작품 알림을 삭제할까요?
+ %1$s
+ %1$s 외 %2$d작품
+ 취소
+ 삭제
+ 알림 설정을 불러오지 못했어요
+ 다시 시도
+
+
+ 웹소소 알림입니다.
+ 푸시 알림 메시지입니다
앱 알림이 꺼져있어요