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작품 + 취소 + 삭제 + 알림 설정을 불러오지 못했어요 + 다시 시도 + + + 웹소소 알림입니다. + 푸시 알림 메시지입니다 앱 알림이 꺼져있어요