From 68decd6346f79762782db067ea888a83346fc163 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 20:46:44 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20=EC=9E=91=ED=92=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20API=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/into/websoso/data/di/ApiModule.kt | 5 ++ .../data/mapper/NovelNotificationMapper.kt | 29 ++++++++++ .../model/NovelNotificationSettingEntity.kt | 6 ++ .../NovelNotificationSubscriptionEntity.kt | 10 ++++ .../NovelNotificationSubscriptionsEntity.kt | 7 +++ .../data/remote/api/NovelNotificationApi.kt | 37 ++++++++++++ .../NovelNotificationSettingRequestDto.kt | 12 ++++ ...tificationSubscriptionsDeleteRequestDto.kt | 12 ++++ .../NovelNotificationSettingResponseDto.kt | 12 ++++ ...velNotificationSubscriptionsResponseDto.kt | 30 ++++++++++ .../repository/NovelNotificationRepository.kt | 56 +++++++++++++++++++ 11 files changed, 216 insertions(+) create mode 100644 app/src/main/java/com/into/websoso/data/mapper/NovelNotificationMapper.kt create mode 100644 app/src/main/java/com/into/websoso/data/model/NovelNotificationSettingEntity.kt create mode 100644 app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionEntity.kt create mode 100644 app/src/main/java/com/into/websoso/data/model/NovelNotificationSubscriptionsEntity.kt create mode 100644 app/src/main/java/com/into/websoso/data/remote/api/NovelNotificationApi.kt create mode 100644 app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSettingRequestDto.kt create mode 100644 app/src/main/java/com/into/websoso/data/remote/request/NovelNotificationSubscriptionsDeleteRequestDto.kt create mode 100644 app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSettingResponseDto.kt create mode 100644 app/src/main/java/com/into/websoso/data/remote/response/NovelNotificationSubscriptionsResponseDto.kt create mode 100644 app/src/main/java/com/into/websoso/data/repository/NovelNotificationRepository.kt 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/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/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/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, + ), + ) + } + } From b2d30cd0bd5a29097d611211217fdeb77baabeb3 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 20:49:35 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20=EC=9E=91=ED=92=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/mapper/NovelNotificationMapper.kt | 32 +++++++++++++++ .../domain/model/NovelNotificationSetting.kt | 6 +++ .../model/NovelNotificationSubscription.kt | 10 +++++ .../model/NovelNotificationSubscriptions.kt | 11 ++++++ .../domain/model/NovelNotificationType.kt | 15 +++++++ ...teNovelNotificationSubscriptionsUseCase.kt | 31 +++++++++++++++ .../GetNovelNotificationSettingUseCase.kt | 21 ++++++++++ ...etNovelNotificationSubscriptionsUseCase.kt | 39 +++++++++++++++++++ .../UpdateNovelNotificationSettingUseCase.kt | 26 +++++++++++++ 9 files changed, 191 insertions(+) create mode 100644 app/src/main/java/com/into/websoso/domain/mapper/NovelNotificationMapper.kt create mode 100644 app/src/main/java/com/into/websoso/domain/model/NovelNotificationSetting.kt create mode 100644 app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscription.kt create mode 100644 app/src/main/java/com/into/websoso/domain/model/NovelNotificationSubscriptions.kt create mode 100644 app/src/main/java/com/into/websoso/domain/model/NovelNotificationType.kt create mode 100644 app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt create mode 100644 app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt create mode 100644 app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt create mode 100644 app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt 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/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..8ca6ac053 --- /dev/null +++ b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt @@ -0,0 +1,31 @@ +package com.into.websoso.domain.usecase + +import com.into.websoso.data.repository.NovelNotificationRepository +import com.into.websoso.domain.model.NovelNotificationType +import javax.inject.Inject + +class DeleteNovelNotificationSubscriptionsUseCase + @Inject + constructor( + private val novelNotificationRepository: NovelNotificationRepository, + ) { + suspend operator fun invoke( + notificationType: NovelNotificationType, + novelIds: List, + ): Result = + try { + novelIds.chunked(MAX_DELETABLE_SIZE).forEach { chunkedNovelIds -> + novelNotificationRepository.deleteNovelNotificationSubscriptions( + notificationType = notificationType.name, + novelIds = chunkedNovelIds, + ) + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + + 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..77ca0484d --- /dev/null +++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt @@ -0,0 +1,21 @@ +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 + +class GetNovelNotificationSettingUseCase + @Inject + constructor( + private val novelNotificationRepository: NovelNotificationRepository, + ) { + suspend operator fun invoke(novelId: Long): Result = + try { + Result.success( + novelNotificationRepository.fetchNovelNotificationSetting(novelId).toDomain(), + ) + } 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..1dac78ab1 --- /dev/null +++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt @@ -0,0 +1,39 @@ +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 + +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: 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..4eb8f679d --- /dev/null +++ b/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt @@ -0,0 +1,26 @@ +package com.into.websoso.domain.usecase + +import com.into.websoso.data.repository.NovelNotificationRepository +import com.into.websoso.domain.model.NovelNotificationSetting +import javax.inject.Inject + +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: Exception) { + Result.failure(e) + } + } From 7aa35411ba66c7c2afb567d96690dc7b2df9ab79 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 20:55:38 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20=EC=9E=91=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=95=8C=EB=A6=BC=20=EB=93=B1=EB=A1=9D=20=EB=B0=94?= =?UTF-8?q?=ED=85=80=EC=8B=9C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작품 상세 상단에 알림 아이콘 추가 - 더보기 아이콘을 미트볼에서 케밥으로 변경 - 완결/휴재 복귀 알림 토글 바텀시트 추가 --- .../ui/novelDetail/NovelDetailActivity.kt | 17 +++ .../novelDetail/NovelDetailClickListener.kt | 2 + .../NovelNotificationBottomSheetDialog.kt | 67 ++++++++++ .../novelDetail/NovelNotificationViewModel.kt | 100 +++++++++++++++ .../model/NovelNotificationUiState.kt | 8 ++ .../main/res/layout/activity_novel_detail.xml | 18 ++- .../res/layout/dialog_novel_notification.xml | 114 ++++++++++++++++++ .../res/drawable/ic_novel_detail_kebab.xml | 15 +++ .../drawable/ic_novel_detail_notification.xml | 10 ++ core/resource/src/main/res/values/strings.xml | 7 ++ 10 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiState.kt create mode 100644 app/src/main/res/layout/dialog_novel_notification.xml create mode 100644 core/resource/src/main/res/drawable/ic_novel_detail_kebab.xml create mode 100644 core/resource/src/main/res/drawable/ic_novel_detail_notification.xml diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt index 2b8b94472..1fb293c52 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt @@ -314,6 +314,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 +409,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() 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/NovelNotificationBottomSheetDialog.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt new file mode 100644 index 000000000..8acd32c17 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt @@ -0,0 +1,67 @@ +package com.into.websoso.ui.novelDetail + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import com.into.websoso.R +import com.into.websoso.core.common.ui.base.BaseBottomSheetDialog +import com.into.websoso.core.common.util.collectWithLifecycle +import com.into.websoso.databinding.DialogNovelNotificationBinding +import com.into.websoso.ui.novelDetail.model.NovelNotificationUiState +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class NovelNotificationBottomSheetDialog : + BaseBottomSheetDialog(R.layout.dialog_novel_notification) { + private val novelNotificationViewModel: NovelNotificationViewModel by viewModels() + private val novelId: Long by lazy { arguments?.getLong(NOVEL_ID) ?: DEFAULT_NOVEL_ID } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + bindClickListener() + setupObserver() + novelNotificationViewModel.updateNovelNotificationSetting(novelId) + } + + private fun bindClickListener() { + binding.onCompletionToggleClick = { + novelNotificationViewModel.updateCompletionNotificationEnabled( + novelId = novelId, + isEnabled = binding.scNovelNotificationCompletionToggle.isChecked.not(), + ) + } + binding.onHiatusReturnToggleClick = { + novelNotificationViewModel.updateHiatusReturnNotificationEnabled( + novelId = novelId, + isEnabled = binding.scNovelNotificationHiatusReturnToggle.isChecked.not(), + ) + } + binding.lifecycleOwner = viewLifecycleOwner + } + + private fun setupObserver() { + novelNotificationViewModel.novelNotificationUiState.collectWithLifecycle(viewLifecycleOwner) { uiState -> + updateToggleState(uiState) + } + } + + private fun updateToggleState(uiState: NovelNotificationUiState) { + binding.scNovelNotificationCompletionToggle.isChecked = uiState.isCompletionNotificationEnabled + binding.scNovelNotificationHiatusReturnToggle.isChecked = uiState.isHiatusReturnNotificationEnabled + } + + 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..c67a5b6ec --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt @@ -0,0 +1,100 @@ +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.Job +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 + + fun updateNovelNotificationSetting(novelId: Long) { + 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, + ) { + _novelNotificationUiState.value = novelNotificationUiState.value.copy( + isCompletionNotificationEnabled = isEnabled, + ) + + saveNovelNotificationSetting(novelId) + } + + fun updateHiatusReturnNotificationEnabled( + novelId: Long, + isEnabled: Boolean, + ) { + _novelNotificationUiState.value = novelNotificationUiState.value.copy( + isHiatusReturnNotificationEnabled = isEnabled, + ) + + saveNovelNotificationSetting(novelId) + } + + private fun saveNovelNotificationSetting(novelId: Long) { + saveJob?.cancel() + saveJob = viewModelScope.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/model/NovelNotificationUiState.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiState.kt new file mode 100644 index 000000000..275868827 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiState.kt @@ -0,0 +1,8 @@ +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, +) 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" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 6348fcb83..13773e312 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -297,6 +297,13 @@ 활동 알림 댓글, 좋아요 등 알림 + + 작품 알림 설정 + 완결 알림 + 작품이 완결 나면 알림을 드려요 + 휴재 복귀 알림 + 새로운 회차가 생기면 알림을 드려요 + 앱 알림이 꺼져있어요 중요한 소식만 전해드릴게요!\n기기 설정에서 알림을 허용해주세요. From 99f3461b0c0dc53e0ba5b063c58afce21a13447b Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 21:01:52 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20=EC=99=84=EA=B2=B0=C2=B7=ED=9C=B4?= =?UTF-8?q?=EC=9E=AC=20=EB=B3=B5=EA=B7=80=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 유형별 구독 목록 조회 및 커서 페이지네이션 - 알림 등록 작품이 없을 때 빈 화면 및 작품 둘러보기 CTA --- app/src/main/AndroidManifest.xml | 4 + .../ui/mapper/NovelNotificationMapper.kt | 14 +++ .../NovelNotificationListActivity.kt | 57 +++++++++++ .../NovelNotificationListScreen.kt | 53 ++++++++++ .../NovelNotificationListViewModel.kt | 78 +++++++++++++++ .../NovelNotificationTypeExtensions.kt | 15 +++ .../component/NovelNotificationEmptyView.kt | 77 +++++++++++++++ .../component/NovelNotificationListAppBar.kt | 72 ++++++++++++++ .../NovelNotificationSubscriptionItem.kt | 98 +++++++++++++++++++ ...NovelNotificationSubscriptionsContainer.kt | 63 ++++++++++++ .../model/NovelNotificationListUiState.kt | 17 ++++ .../NovelNotificationSubscriptionModel.kt | 10 ++ core/resource/src/main/res/values/strings.xml | 3 + 13 files changed, 561 insertions(+) create mode 100644 app/src/main/java/com/into/websoso/ui/mapper/NovelNotificationMapper.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListActivity.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationTypeExtensions.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionItem.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionsContainer.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiState.kt create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationSubscriptionModel.kt 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" /> + Unit, + onExploreClick: () -> Unit, + onBackButtonClick: () -> Unit, +) { + val uiState by viewModel.novelNotificationListUiState.collectAsStateWithLifecycle() + + BackHandler { + onBackButtonClick() + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(White) + .windowInsetsPadding(WindowInsets.systemBars), + ) { + NovelNotificationListAppBar( + notificationType = uiState.notificationType, + onBackButtonClick = onBackButtonClick, + ) + when { + uiState.isEmpty -> NovelNotificationEmptyView(onExploreClick = onExploreClick) + else -> NovelNotificationSubscriptionsContainer( + subscriptions = uiState.subscriptions, + isLoadable = uiState.isLoadable, + updateSubscriptions = viewModel::updateSubscriptions, + onSubscriptionClick = onSubscriptionClick, + ) + } + } +} 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..69960dab8 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt @@ -0,0 +1,78 @@ +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.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, + ) : 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 + + 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() }, + ) + } + + private fun handleFailureState() { + _novelNotificationListUiState.value = novelNotificationListUiState.value.copy( + isLoading = false, + isError = true, + isInitialLoaded = true, + ) + } + } 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/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/NovelNotificationListAppBar.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt new file mode 100644 index 000000000..959aec85a --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt @@ -0,0 +1,72 @@ +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.WebsosoTheme +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R.drawable.ic_notification_back +import com.into.websoso.domain.model.NovelNotificationType +import com.into.websoso.domain.model.NovelNotificationType.COMPLETION +import com.into.websoso.ui.novelNotification.novelNotificationTitleRes + +@Composable +fun NovelNotificationListAppBar( + notificationType: NovelNotificationType, + onBackButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .background(White) + .fillMaxWidth() + .padding(horizontal = 6.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(end = 44.dp), + ) + } +} + +@Preview +@Composable +private fun NovelNotificationListAppBarPreview() { + WebsosoTheme { + NovelNotificationListAppBar( + notificationType = COMPLETION, + onBackButtonClick = {}, + ) + } +} 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..a1c5aef69 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionItem.kt @@ -0,0 +1,98 @@ +package com.into.websoso.ui.novelNotification.component + +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.layout.ContentScale.Companion.Crop +import androidx.compose.ui.res.stringResource +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.string.novel_notification_registered_date +import com.into.websoso.ui.novelNotification.model.NovelNotificationSubscriptionModel + +@Composable +fun NovelNotificationSubscriptionItem( + subscription: NovelNotificationSubscriptionModel, + 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, + ) + } + Spacer(modifier = Modifier.size(44.dp)) + } +} + +@Preview +@Composable +private fun NovelNotificationSubscriptionItemPreview() { + WebsosoTheme { + NovelNotificationSubscriptionItem( + subscription = NovelNotificationSubscriptionModel( + subscriptionId = 1, + novelId = 1, + novelTitle = "여주인공의 이해를 돕기 위하여", + novelAuthor = "이보라", + novelImage = "", + registeredDate = "2026.07.04", + ), + ) + } +} 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..f7ef2ea66 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationSubscriptionsContainer.kt @@ -0,0 +1,63 @@ +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, + isLoadable: Boolean, + updateSubscriptions: () -> Unit, + onSubscriptionClick: (NovelNotificationSubscriptionModel) -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + LaunchedEffect(listState, isLoadable) { + 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, + modifier = Modifier.clickableWithoutRipple { onSubscriptionClick(subscription) }, + ) + } + } +} + +@Preview +@Composable +private fun NovelNotificationSubscriptionsContainerPreview() { + WebsosoTheme { + NovelNotificationSubscriptionsContainer( + subscriptions = emptyList(), + isLoadable = false, + updateSubscriptions = {}, + onSubscriptionClick = {}, + ) + } +} 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..95e28cc23 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiState.kt @@ -0,0 +1,17 @@ +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 lastSubscriptionId: Long = DEFAULT_SUBSCRIPTION_ID, + val subscriptions: List = emptyList(), +) { + val isEmpty: Boolean get() = isInitialLoaded && subscriptions.isEmpty() +} 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/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 13773e312..bc5e4ecb2 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -303,6 +303,9 @@ 작품이 완결 나면 알림을 드려요 휴재 복귀 알림 새로운 회차가 생기면 알림을 드려요 + %1$s에 알림 등록 + 알림 등록한 작품이 없어요 + 작품 둘러보기 앱 알림이 꺼져있어요 From 623fc0d0e208c4a92e61b946c93868d9fa7b1551 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 21:04:24 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=99=84=EA=B2=B0?= =?UTF-8?q?=C2=B7=ED=9C=B4=EC=9E=AC=20=EB=B3=B5=EA=B7=80=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A7=84=EC=9E=85=EC=A0=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationSettingActivity.kt | 10 ++ .../layout/activity_notification_setting.xml | 134 +++++++++++++++++- core/resource/src/main/res/values/strings.xml | 4 + 3 files changed, 143 insertions(+), 5 deletions(-) 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 + + + + @@ -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/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index bc5e4ecb2..be2891c5f 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -296,6 +296,10 @@ 활동 알림 댓글, 좋아요 등 알림 + 완결 알림 + 작품이 완결나면 알림을 드려요 + 휴재 복귀, 외전 알림 + 휴재가 끝나거나 새로운 회차가 생기면 알림을 드려요 작품 알림 설정 From c272d24b644b174eee25d6e4c1dddc0a51fc97fc Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 21:10:11 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=B8=EC=A7=91=20=EB=B0=8F=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수정/삭제 앱바 액션 및 편집 모드 전환 - 작품 선택 체크 UI 및 삭제 알럿 추가 --- .../NovelNotificationListScreen.kt | 22 ++- .../NovelNotificationListViewModel.kt | 51 +++++++ .../NovelNotificationDeleteDialog.kt | 139 ++++++++++++++++++ .../component/NovelNotificationListAppBar.kt | 52 ++++++- .../NovelNotificationSubscriptionItem.kt | 24 ++- ...NovelNotificationSubscriptionsContainer.kt | 15 +- .../model/NovelNotificationListUiState.kt | 8 + .../ic_novel_notification_selected.xml | 14 ++ .../ic_novel_notification_unselected.xml | 14 ++ core/resource/src/main/res/values/strings.xml | 7 + 10 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationDeleteDialog.kt create mode 100644 core/resource/src/main/res/drawable/ic_novel_notification_selected.xml create mode 100644 core/resource/src/main/res/drawable/ic_novel_notification_unselected.xml 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 index 3a2ed274e..106dc0bcb 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.NovelNotificationListAppBar import com.into.websoso.ui.novelNotification.component.NovelNotificationSubscriptionsContainer @@ -27,7 +28,10 @@ fun NovelNotificationListScreen( val uiState by viewModel.novelNotificationListUiState.collectAsStateWithLifecycle() BackHandler { - onBackButtonClick() + when (uiState.isEditing) { + true -> viewModel.updateEditing(false) + false -> onBackButtonClick() + } } Column( @@ -38,16 +42,32 @@ fun NovelNotificationListScreen( ) { NovelNotificationListAppBar( notificationType = uiState.notificationType, + isEditing = uiState.isEditing, + isDeletable = uiState.isDeletable, + isActionVisible = uiState.isEmpty.not(), onBackButtonClick = onBackButtonClick, + onEditButtonClick = { viewModel.updateEditing(true) }, + onDeleteButtonClick = { viewModel.updateDeleteDialogVisibility(true) }, ) when { uiState.isEmpty -> NovelNotificationEmptyView(onExploreClick = onExploreClick) else -> NovelNotificationSubscriptionsContainer( subscriptions = uiState.subscriptions, + selectedNovelIds = uiState.selectedNovelIds, + isEditing = uiState.isEditing, isLoadable = uiState.isLoadable, updateSubscriptions = viewModel::updateSubscriptions, onSubscriptionClick = onSubscriptionClick, + onSubscriptionSelect = { viewModel.updateSelectedNovel(it.novelId) }, ) } } + + if (uiState.isDeleteDialogVisible) { + NovelNotificationDeleteDialog( + selectedSubscriptions = uiState.selectedSubscriptions, + onCancelClick = { viewModel.updateDeleteDialogVisibility(false) }, + onConfirmClick = viewModel::deleteSelectedSubscriptions, + ) + } } 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 index 69960dab8..9b2ea99b0 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt @@ -5,6 +5,7 @@ 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 @@ -21,6 +22,7 @@ class NovelNotificationListViewModel constructor( savedStateHandle: SavedStateHandle, private val getNovelNotificationSubscriptionsUseCase: GetNovelNotificationSubscriptionsUseCase, + private val deleteNovelNotificationSubscriptionsUseCase: DeleteNovelNotificationSubscriptionsUseCase, ) : ViewModel() { private val notificationType: NovelNotificationType = NovelNotificationType.from( savedStateHandle.get(NOVEL_NOTIFICATION_TYPE).orEmpty(), @@ -75,4 +77,53 @@ class NovelNotificationListViewModel 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 { + handleDeleteSuccessState(selectedNovelIds) + }.onFailure { + updateDeleteDialogVisibility(false) + } + } + } + + private fun handleDeleteSuccessState(deletedNovelIds: Set) { + val currentUiState = novelNotificationListUiState.value + _novelNotificationListUiState.value = currentUiState.copy( + isEditing = false, + isDeleteDialogVisible = false, + selectedNovelIds = emptySet(), + subscriptions = currentUiState.subscriptions.filterNot { it.novelId in deletedNovelIds }, + ) + } } 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..c01b9d504 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationDeleteDialog.kt @@ -0,0 +1,139 @@ +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, + ) + } +} + +@Preview +@Composable +private fun NovelNotificationDeleteDialogPreview() { + WebsosoTheme { + NovelNotificationDeleteDialog( + selectedSubscriptions = listOf( + NovelNotificationSubscriptionModel( + subscriptionId = 1, + novelId = 1, + novelTitle = "여주인공의 이해를 돕기 위하여", + novelAuthor = "이보라", + novelImage = "", + registeredDate = "2026.07.04", + ), + ), + onCancelClick = {}, + onConfirmClick = {}, + ) + } +} 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 index 959aec85a..946de40e5 100644 --- 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 @@ -19,9 +19,13 @@ 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.ui.novelNotification.novelNotificationTitleRes @@ -29,14 +33,19 @@ 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(horizontal = 6.dp) + .padding(start = 6.dp, end = 20.dp) .height(44.dp), verticalAlignment = CenterVertically, ) { @@ -55,7 +64,41 @@ fun NovelNotificationListAppBar( textAlign = Center, modifier = Modifier .weight(1f) - .padding(end = 44.dp), + .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() }, ) } } @@ -66,7 +109,12 @@ private fun NovelNotificationListAppBarPreview() { WebsosoTheme { NovelNotificationListAppBar( notificationType = COMPLETION, + isEditing = false, + isDeletable = false, + isActionVisible = true, 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 index a1c5aef69..4ce1d2474 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -15,8 +16,10 @@ 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 @@ -24,12 +27,16 @@ 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( @@ -76,7 +83,20 @@ fun NovelNotificationSubscriptionItem( maxLines = 1, ) } - Spacer(modifier = Modifier.size(44.dp)) + 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)) + } } } @@ -93,6 +113,8 @@ private fun NovelNotificationSubscriptionItemPreview() { novelImage = "", registeredDate = "2026.07.04", ), + isEditing = true, + isSelected = true, ) } } 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 index f7ef2ea66..8bef00187 100644 --- 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 @@ -17,9 +17,12 @@ 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() @@ -43,7 +46,14 @@ fun NovelNotificationSubscriptionsContainer( ) { subscription -> NovelNotificationSubscriptionItem( subscription = subscription, - modifier = Modifier.clickableWithoutRipple { onSubscriptionClick(subscription) }, + isEditing = isEditing, + isSelected = subscription.novelId in selectedNovelIds, + modifier = Modifier.clickableWithoutRipple { + when (isEditing) { + true -> onSubscriptionSelect(subscription) + false -> onSubscriptionClick(subscription) + } + }, ) } } @@ -55,9 +65,12 @@ private fun NovelNotificationSubscriptionsContainerPreview() { WebsosoTheme { NovelNotificationSubscriptionsContainer( subscriptions = emptyList(), + selectedNovelIds = emptySet(), + isEditing = false, 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 index 95e28cc23..e5594e98c 100644 --- 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 @@ -10,8 +10,16 @@ data class NovelNotificationListUiState( 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 && subscriptions.isEmpty() + + val isDeletable: Boolean get() = selectedNovelIds.isNotEmpty() + + val selectedSubscriptions: List + get() = subscriptions.filter { it.novelId in selectedNovelIds } } 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 be2891c5f..42c8c5417 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -310,6 +310,13 @@ %1$s에 알림 등록 알림 등록한 작품이 없어요 작품 둘러보기 + 수정 + 삭제 + 해당 작품 알림을 삭제할까요? + %1$s + %1$s 외 %2$d작품 + 취소 + 삭제 앱 알림이 꺼져있어요 From c60affaddf7075df4313c0674a2b787dbe150113 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 21:52:09 +0900 Subject: [PATCH 07/27] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=20=EC=95=8C?= =?UTF-8?q?=EB=9F=BF=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EC=B2=98=EC=9D=8C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=95=9C=20=EC=9E=91=ED=92=88=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 순서가 아닌 선택 순서의 첫 작품을 기준으로 문구를 구성한다. --- .../novelNotification/model/NovelNotificationListUiState.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index e5594e98c..ef8c41abc 100644 --- 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 @@ -20,6 +20,9 @@ data class NovelNotificationListUiState( val isDeletable: Boolean get() = selectedNovelIds.isNotEmpty() + // 삭제 알럿은 '처음 선택한 작품'을 기준으로 문구를 구성하므로 목록 순서가 아닌 선택 순서를 유지한다 val selectedSubscriptions: List - get() = subscriptions.filter { it.novelId in selectedNovelIds } + get() = selectedNovelIds.mapNotNull { novelId -> + subscriptions.find { it.novelId == novelId } + } } From 98604d2058f560c62cda3bd0984e3413b8171389 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 22:45:12 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20=EC=99=84=EA=B2=B0=C2=B7=ED=9C=B4?= =?UTF-8?q?=EC=9E=AC=20=EB=B3=B5=EA=B7=80=20=EC=95=8C=EB=A6=BC=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=EC=9E=91=ED=92=88=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 응답의 novelId 필드 파싱 추가 - NotificationType에 NOVEL 추가 및 인앱 알림 리스트 이동 처리 - 푸시 알림 탭 시 작품 상세로 이동하도록 분기 추가 --- .../common/util/message/WSSFirebaseMessagingService.kt | 5 +++++ .../com/into/websoso/data/mapper/NotificationMapper.kt | 1 + .../java/com/into/websoso/data/model/NotificationEntity.kt | 2 ++ .../data/remote/response/NotificationsResponseDto.kt | 2 ++ .../com/into/websoso/domain/mapper/NotificationMapper.kt | 1 + .../java/com/into/websoso/domain/model/Notification.kt | 2 ++ .../java/com/into/websoso/domain/model/NotificationType.kt | 2 ++ .../into/websoso/ui/notification/NotificationActivity.kt | 7 +++++++ .../com/into/websoso/ui/notification/NotificationScreen.kt | 2 ++ .../ui/notification/component/NotificationsContainer.kt | 5 +++++ 10 files changed, 29 insertions(+) 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..733cfe430 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 @@ -13,6 +13,7 @@ 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 @@ -33,11 +34,13 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() { val title = receivedData["title"] ?: DEFAULT_TITLE val body = receivedData["body"] ?: DEFAULT_BODY val feedId = receivedData["feedId"]?.toLongOrNull() + val novelId = receivedData["novelId"]?.toLongOrNull() val notificationId = receivedData["notificationId"]?.toLong() ?: return setupNotificationChannel() val pendingIntent = createPendingIntent( feedId, + novelId, notificationId, ) showNotification(title, body, pendingIntent) @@ -58,12 +61,14 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() { private fun createPendingIntent( feedId: Long?, + novelId: Long?, notificationId: Long, ): PendingIntent { val mainIntent = MainActivity.getIntent(this) val detailIntent = when { feedId != null -> FeedDetailActivity.getIntent(this, feedId, notificationId) + novelId != null -> NovelDetailActivity.getIntent(this, novelId) else -> NotificationDetailActivity.getIntent(this, notificationId) } 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/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/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/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/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/ui/notification/NotificationActivity.kt b/app/src/main/java/com/into/websoso/ui/notification/NotificationActivity.kt index 15f1f718a..b74cc43af 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)) + } + 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 = {}, ) } } From 44c3b04a098481e7744307ff5fb5b2358ee06811 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 22:56:22 +0900 Subject: [PATCH 09/27] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=B6=84=EA=B8=B0=20=EB=B0=8F=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9E=90=20=EB=A7=A4=ED=95=91=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버에 완결·휴재 알림 데이터가 없어 기기에서 검증할 수 없는 NOVEL 분기를 단위 테스트로 고정한다. --- .../domain/model/NotificationTypeTest.kt | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 app/src/test/java/com/into/websoso/domain/model/NotificationTypeTest.kt diff --git a/app/src/test/java/com/into/websoso/domain/model/NotificationTypeTest.kt b/app/src/test/java/com/into/websoso/domain/model/NotificationTypeTest.kt new file mode 100644 index 000000000..50aea6e7b --- /dev/null +++ b/app/src/test/java/com/into/websoso/domain/model/NotificationTypeTest.kt @@ -0,0 +1,93 @@ +package com.into.websoso.domain.model + +import com.into.websoso.data.model.NotificationEntity +import com.into.websoso.data.repository.NotificationRepository.Companion.DEFAULT_INTRINSIC_ID +import org.junit.Assert.assertEquals +import org.junit.Test + +class NotificationTypeTest { + @Test + fun `공지 알림은 NOTICE 타입이고 공지 식별자로 알림 ID를 사용한다`() { + val entity = notificationEntity(isNotice = true, feedId = null, novelId = null) + + assertEquals(NotificationType.NOTICE, entity.toNotification().getNotificationType()) + assertEquals(NOTIFICATION_ID, entity.getIntrinsicId()) + } + + @Test + fun `피드 알림은 FEED 타입이고 식별자로 피드 ID를 사용한다`() { + val entity = notificationEntity(isNotice = false, feedId = FEED_ID, novelId = null) + + assertEquals(NotificationType.FEED, entity.toNotification().getNotificationType()) + assertEquals(FEED_ID, entity.getIntrinsicId()) + } + + @Test + fun `작품 알림은 NOVEL 타입이고 식별자로 작품 ID를 사용한다`() { + val entity = notificationEntity(isNotice = false, feedId = null, novelId = NOVEL_ID) + + assertEquals(NotificationType.NOVEL, entity.toNotification().getNotificationType()) + assertEquals(NOVEL_ID, entity.getIntrinsicId()) + } + + @Test + fun `공지와 작품 ID가 함께 오면 기존 동작대로 공지를 우선한다`() { + val entity = notificationEntity(isNotice = true, feedId = null, novelId = NOVEL_ID) + + assertEquals(NotificationType.NOTICE, entity.toNotification().getNotificationType()) + assertEquals(NOTIFICATION_ID, entity.getIntrinsicId()) + } + + @Test + fun `피드와 작품 ID가 함께 오면 기존 동작대로 피드를 우선한다`() { + val entity = notificationEntity(isNotice = false, feedId = FEED_ID, novelId = NOVEL_ID) + + assertEquals(NotificationType.FEED, entity.toNotification().getNotificationType()) + assertEquals(FEED_ID, entity.getIntrinsicId()) + } + + @Test + fun `식별자가 없는 알림은 NONE 타입이고 기본 식별자를 사용한다`() { + val entity = notificationEntity(isNotice = false, feedId = null, novelId = null) + + assertEquals(NotificationType.NONE, entity.toNotification().getNotificationType()) + assertEquals(DEFAULT_INTRINSIC_ID, entity.getIntrinsicId()) + } + + private fun notificationEntity( + isNotice: Boolean, + feedId: Long?, + novelId: Long?, + ): NotificationEntity = + NotificationEntity( + notificationId = NOTIFICATION_ID, + notificationImage = "", + notificationTitle = "", + notificationBody = "", + createdDate = "", + isRead = false, + isNotice = isNotice, + feedId = feedId, + novelId = novelId, + ) + + private fun NotificationEntity.toNotification(): Notification = + Notification( + notificationId = notificationId, + notificationIconImage = notificationImage, + notificationTitle = notificationTitle, + notificationDescription = notificationBody, + createdDate = createdDate, + isRead = isRead, + isNotice = isNotice, + feedId = feedId, + novelId = novelId, + intrinsicId = getIntrinsicId(), + ) + + companion object { + private const val NOTIFICATION_ID = 100L + private const val FEED_ID = 200L + private const val NOVEL_ID = 300L + } +} From 69f886bf67da41d0b35a02e6640f45bd84f7f9ff Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 23:16:22 +0900 Subject: [PATCH 10/27] =?UTF-8?q?refactor:=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20PushMessage=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버가 값 없는 필드를 빈 문자열로 내려주므로 알림 ID도 toLongOrNull로 파싱해 NumberFormatException을 방지한다. --- .../core/common/util/message/PushMessage.kt | 45 +++++++++ .../message/WSSFirebaseMessagingService.kt | 46 ++++----- .../common/util/message/PushMessageTest.kt | 97 +++++++++++++++++++ 3 files changed, 163 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt create mode 100644 app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt diff --git a/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt new file mode 100644 index 000000000..20995afe2 --- /dev/null +++ b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt @@ -0,0 +1,45 @@ +package com.into.websoso.core.common.util.message + +/** + * 푸시 알림의 data 페이로드를 파싱한 결과. + * + * 서버는 값이 없는 필드를 null이 아닌 빈 문자열로 내려주므로 + * (예: 작품 알림의 `"feedId": ""`) 모든 ID는 [String.toLongOrNull]로 파싱한다. + */ +data class PushMessage( + val title: String, + val body: String, + val feedId: Long?, + val novelId: Long?, + val notificationId: Long, +) { + val destination: PushDestination + get() = when { + feedId != null -> PushDestination.FEED + novelId != null -> PushDestination.NOVEL + else -> PushDestination.NOTIFICATION_DETAIL + } + + companion object { + const val DEFAULT_TITLE = "웹소소" + const val DEFAULT_BODY = "푸시 알림 메시지입니다" + + fun from(data: Map): PushMessage? { + val notificationId = data["notificationId"]?.toLongOrNull() ?: return null + + return PushMessage( + title = data["title"] ?: DEFAULT_TITLE, + body = data["body"] ?: DEFAULT_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 733cfe430..df47ecd90 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 @@ -28,22 +28,11 @@ 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 novelId = receivedData["novelId"]?.toLongOrNull() - val notificationId = receivedData["notificationId"]?.toLong() ?: return + val pushMessage = PushMessage.from(message.data) ?: return setupNotificationChannel() - val pendingIntent = createPendingIntent( - feedId, - novelId, - notificationId, - ) - showNotification(title, body, pendingIntent) + val pendingIntent = createPendingIntent(pushMessage) + showNotification(pushMessage.title, pushMessage.body, pendingIntent) } private fun setupNotificationChannel() { @@ -59,17 +48,26 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() { notificationManager.createNotificationChannel(channel) } - private fun createPendingIntent( - feedId: Long?, - novelId: 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) - novelId != null -> NovelDetailActivity.getIntent(this, novelId) - else -> NotificationDetailActivity.getIntent(this, notificationId) + PushDestination.NOVEL -> NovelDetailActivity.getIntent( + this, + requireNotNull(pushMessage.novelId), + ) + + PushDestination.NOTIFICATION_DETAIL -> NotificationDetailActivity.getIntent( + this, + notificationId, + ) } return TaskStackBuilder.create(this).run { @@ -113,8 +111,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/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt b/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt new file mode 100644 index 000000000..6c78ea1d0 --- /dev/null +++ b/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt @@ -0,0 +1,97 @@ +package com.into.websoso.core.common.util.message + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PushMessageTest { + @Test + fun `휴재 복귀 알림 페이로드는 작품 상세로 이동한다`() { + val pushMessage = PushMessage.from(HIATUS_RETURN_PAYLOAD) + + assertEquals(PushDestination.NOVEL, pushMessage?.destination) + assertEquals(12345L, pushMessage?.novelId) + assertEquals(67890L, pushMessage?.notificationId) + } + + @Test + fun `값이 없는 필드가 빈 문자열로 와도 null로 파싱한다`() { + val pushMessage = PushMessage.from(HIATUS_RETURN_PAYLOAD) + + assertNull(pushMessage?.feedId) + } + + @Test + fun `제목과 내용을 페이로드에서 그대로 사용한다`() { + val pushMessage = PushMessage.from(HIATUS_RETURN_PAYLOAD) + + assertEquals("휴재 복귀 알림", pushMessage?.title) + assertEquals("작품명 작품에 새로운 회차가 올라왔어요.", pushMessage?.body) + } + + @Test + fun `피드 알림은 작품 ID가 없으므로 피드 상세로 이동한다`() { + val pushMessage = PushMessage.from( + mapOf( + "title" to "댓글 알림", + "body" to "내 글에 댓글이 달렸어요.", + "feedId" to "4508", + "novelId" to "", + "view" to "", + "notificationId" to "3764", + ), + ) + + assertEquals(PushDestination.FEED, pushMessage?.destination) + assertEquals(4508L, pushMessage?.feedId) + } + + @Test + fun `피드와 작품 ID가 모두 없으면 알림 상세로 이동한다`() { + val pushMessage = PushMessage.from( + mapOf( + "title" to "공지", + "body" to "공지사항입니다.", + "feedId" to "", + "novelId" to "", + "notificationId" to "3746", + ), + ) + + assertEquals(PushDestination.NOTIFICATION_DETAIL, pushMessage?.destination) + } + + @Test + fun `알림 ID가 빈 문자열이면 예외 대신 null을 반환한다`() { + val pushMessage = PushMessage.from( + mapOf( + "title" to "휴재 복귀 알림", + "body" to "새로운 회차가 올라왔어요.", + "novelId" to "12345", + "notificationId" to "", + ), + ) + + assertNull(pushMessage) + } + + @Test + fun `제목과 내용이 없으면 기본값을 사용한다`() { + val pushMessage = PushMessage.from(mapOf("notificationId" to "1")) + + assertEquals(PushMessage.DEFAULT_TITLE, pushMessage?.title) + assertEquals(PushMessage.DEFAULT_BODY, pushMessage?.body) + } + + companion object { + // 서버가 실제로 내려주는 휴재 복귀 알림 페이로드 + private val HIATUS_RETURN_PAYLOAD = mapOf( + "title" to "휴재 복귀 알림", + "body" to "작품명 작품에 새로운 회차가 올라왔어요.", + "feedId" to "", + "novelId" to "12345", + "view" to "", + "notificationId" to "67890", + ) + } +} From 68ce374fbe80614d29c66e0769f948d1ec05549b Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 28 Aug 2026 00:30:17 +0900 Subject: [PATCH 11/27] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../into/websoso/core/common/util/message/PushMessage.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt index 20995afe2..008aaf823 100644 --- a/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt +++ b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt @@ -1,11 +1,6 @@ package com.into.websoso.core.common.util.message -/** - * 푸시 알림의 data 페이로드를 파싱한 결과. - * - * 서버는 값이 없는 필드를 null이 아닌 빈 문자열로 내려주므로 - * (예: 작품 알림의 `"feedId": ""`) 모든 ID는 [String.toLongOrNull]로 파싱한다. - */ + data class PushMessage( val title: String, val body: String, From a6267b329fb6370c872037d5fc06f16f4b8c2f1d Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 28 Aug 2026 01:11:10 +0900 Subject: [PATCH 12/27] =?UTF-8?q?fix:=20ktlint=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websoso/core/common/util/message/PushMessage.kt | 1 - .../NovelNotificationBottomSheetDialog.kt | 3 +-- .../NovelNotificationListScreen.kt | 1 + .../NovelNotificationSubscriptionsContainer.kt | 13 ++++++++----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt index 008aaf823..b76d2822a 100644 --- a/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt +++ b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt @@ -1,6 +1,5 @@ package com.into.websoso.core.common.util.message - data class PushMessage( val title: String, val body: String, 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 index 8acd32c17..a067b9819 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt @@ -11,8 +11,7 @@ import com.into.websoso.ui.novelDetail.model.NovelNotificationUiState import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class NovelNotificationBottomSheetDialog : - BaseBottomSheetDialog(R.layout.dialog_novel_notification) { +class NovelNotificationBottomSheetDialog : BaseBottomSheetDialog(R.layout.dialog_novel_notification) { private val novelNotificationViewModel: NovelNotificationViewModel by viewModels() private val novelId: Long by lazy { arguments?.getLong(NOVEL_ID) ?: DEFAULT_NOVEL_ID } 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 index 106dc0bcb..56c1a12b2 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt @@ -51,6 +51,7 @@ fun NovelNotificationListScreen( ) when { uiState.isEmpty -> NovelNotificationEmptyView(onExploreClick = onExploreClick) + else -> NovelNotificationSubscriptionsContainer( subscriptions = uiState.subscriptions, selectedNovelIds = uiState.selectedNovelIds, 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 index 8bef00187..ae2564ce9 100644 --- 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 @@ -28,12 +28,15 @@ fun NovelNotificationSubscriptionsContainer( val listState = rememberLazyListState() LaunchedEffect(listState, isLoadable) { - snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 } - .collect { index -> - if (index + LOAD_THRESHOLD >= subscriptions.size && isLoadable) { - updateSubscriptions() - } + snapshotFlow { + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + }.collect { index -> + if (index + LOAD_THRESHOLD >= subscriptions.size && isLoadable) { + updateSubscriptions() } + } } LazyColumn( From b1b2869a281b221a49513664ba8b8b255a848cfa Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 28 Aug 2026 03:23:01 +0900 Subject: [PATCH 13/27] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=ED=9B=84=20=EB=AA=A9=EB=A1=9D=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 초기 조회 성공 전에는 토글 저장을 차단해 건드리지 않은 알림이 해제되지 않도록 한다 - 로드된 항목을 모두 삭제해도 다음 페이지가 남아 있으면 이어서 조회한다 --- .../NovelNotificationBottomSheetDialog.kt | 9 ++++ .../novelDetail/NovelNotificationViewModel.kt | 4 ++ .../model/NovelNotificationUiState.kt | 5 +- .../NovelNotificationListViewModel.kt | 7 ++- .../model/NovelNotificationUiStateTest.kt | 28 ++++++++++ .../model/NovelNotificationListUiStateTest.kt | 52 +++++++++++++++++++ 6 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/into/websoso/ui/novelDetail/model/NovelNotificationUiStateTest.kt create mode 100644 app/src/test/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiStateTest.kt 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 index a067b9819..602bb63a3 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt @@ -51,12 +51,21 @@ class NovelNotificationBottomSheetDialog : BaseBottomSheetDialog) { val currentUiState = novelNotificationListUiState.value + val remainedSubscriptions = currentUiState.subscriptions.filterNot { it.novelId in deletedNovelIds } + _novelNotificationListUiState.value = currentUiState.copy( isEditing = false, isDeleteDialogVisible = false, selectedNovelIds = emptySet(), - subscriptions = currentUiState.subscriptions.filterNot { it.novelId in deletedNovelIds }, + subscriptions = remainedSubscriptions, ) + + // 로드된 항목을 모두 삭제해도 다음 페이지가 남아 있으면 빈 화면 대신 이어서 불러온다 + if (remainedSubscriptions.isEmpty() && currentUiState.isLoadable) updateSubscriptions() } } 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..1891f9016 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiStateTest.kt @@ -0,0 +1,52 @@ +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(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", + ) +} From 7f9d005c6658534d5625ba093dda9737dfd3252f Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:04:37 +0900 Subject: [PATCH 14/27] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=EC=9D=B4=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=81=9D=EC=97=90=20=EB=8F=84=EB=8B=AC=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=95=84=EB=8F=84=20=EB=8B=A4=EC=9D=8C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EC=9A=94=EC=B2=AD=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LaunchedEffect가 생성 시점의 subscriptions를 캡처해 페이지가 도착해도 size 0을 기준으로 판단하고 있었습니다. 키에 subscriptions.size를 추가해 최신 목록 개수로 임계값을 계산하도록 했습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885415707 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../component/NovelNotificationSubscriptionsContainer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ae2564ce9..4bdfd5739 100644 --- 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 @@ -27,7 +27,7 @@ fun NovelNotificationSubscriptionsContainer( ) { val listState = rememberLazyListState() - LaunchedEffect(listState, isLoadable) { + LaunchedEffect(listState, isLoadable, subscriptions.size) { snapshotFlow { listState.layoutInfo.visibleItemsInfo .lastOrNull() From 6b4ba97e0f0ec53f7ddb18cf8c95a13346b33092 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:04:57 +0900 Subject: [PATCH 15/27] =?UTF-8?q?refactor:=20=EC=9E=91=ED=92=88=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=99=94=EB=A9=B4=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EC=A0=80=EB=B8=94=EC=97=90=20=EC=83=81=ED=83=9C=EB=B3=84=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=B7=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boolean 상태 조합마다 프리뷰가 없어 상태별 렌더링을 확인하기 어려웠습니다. 앱바(기본/편집/삭제 활성/액션 숨김), 구독 아이템(기본/편집/선택/긴 텍스트), 컨테이너(목록/편집), 삭제 다이얼로그(단일/복수) 프리뷰를 추가했습니다. 목록 화면은 ViewModel을 직접 받아 프리뷰를 만들 수 없어 stateless 컴포저블로 분리하고 목록·편집·빈 상태·삭제 다이얼로그 프리뷰를 붙였습니다. BackHandler는 프리뷰에서 렌더링이 깨지지 않도록 stateful 래퍼에 남겼습니다. 기존 컨테이너 프리뷰가 빈 리스트라 아무것도 그려지지 않던 것도 함께 고쳤습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885301610 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../NovelNotificationListScreen.kt | 123 +++++++++++++++++- .../NovelNotificationDeleteDialog.kt | 50 +++++-- .../component/NovelNotificationListAppBar.kt | 49 +++++++ .../NovelNotificationSubscriptionItem.kt | 57 ++++++-- ...NovelNotificationSubscriptionsContainer.kt | 30 ++++- 5 files changed, 284 insertions(+), 25 deletions(-) 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 index 56c1a12b2..b8114b2ee 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt @@ -10,12 +10,15 @@ 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.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 @@ -34,6 +37,33 @@ fun NovelNotificationListScreen( } } + 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 = onBackButtonClick, + ) +} + +@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() @@ -46,8 +76,8 @@ fun NovelNotificationListScreen( isDeletable = uiState.isDeletable, isActionVisible = uiState.isEmpty.not(), onBackButtonClick = onBackButtonClick, - onEditButtonClick = { viewModel.updateEditing(true) }, - onDeleteButtonClick = { viewModel.updateDeleteDialogVisibility(true) }, + onEditButtonClick = onEditButtonClick, + onDeleteButtonClick = onDeleteButtonClick, ) when { uiState.isEmpty -> NovelNotificationEmptyView(onExploreClick = onExploreClick) @@ -57,9 +87,9 @@ fun NovelNotificationListScreen( selectedNovelIds = uiState.selectedNovelIds, isEditing = uiState.isEditing, isLoadable = uiState.isLoadable, - updateSubscriptions = viewModel::updateSubscriptions, + updateSubscriptions = updateSubscriptions, onSubscriptionClick = onSubscriptionClick, - onSubscriptionSelect = { viewModel.updateSelectedNovel(it.novelId) }, + onSubscriptionSelect = onSubscriptionSelect, ) } } @@ -67,8 +97,89 @@ fun NovelNotificationListScreen( if (uiState.isDeleteDialogVisible) { NovelNotificationDeleteDialog( selectedSubscriptions = uiState.selectedSubscriptions, - onCancelClick = { viewModel.updateDeleteDialogVisibility(false) }, - onConfirmClick = viewModel::deleteSelectedSubscriptions, + 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 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/component/NovelNotificationDeleteDialog.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationDeleteDialog.kt index c01b9d504..9dbc160bb 100644 --- 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 @@ -117,21 +117,51 @@ private fun NovelNotificationDeleteDialogButton( } } +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 = listOf( - NovelNotificationSubscriptionModel( - subscriptionId = 1, - novelId = 1, - novelTitle = "여주인공의 이해를 돕기 위하여", - novelAuthor = "이보라", - novelImage = "", - registeredDate = "2026.07.04", - ), - ), + 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/NovelNotificationListAppBar.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt index 946de40e5..22008f359 100644 --- 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 @@ -28,6 +28,7 @@ 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 @@ -118,3 +119,51 @@ private fun NovelNotificationListAppBarPreview() { ) } } + +@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 index 4ce1d2474..74b08abd6 100644 --- 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 @@ -100,21 +100,62 @@ fun NovelNotificationSubscriptionItem( } } +private val previewSubscription = NovelNotificationSubscriptionModel( + subscriptionId = 1, + novelId = 1, + novelTitle = "여주인공의 이해를 돕기 위하여", + novelAuthor = "이보라", + novelImage = "", + registeredDate = "2026.07.04", +) + @Preview @Composable private fun NovelNotificationSubscriptionItemPreview() { WebsosoTheme { NovelNotificationSubscriptionItem( - subscription = NovelNotificationSubscriptionModel( - subscriptionId = 1, - novelId = 1, - novelTitle = "여주인공의 이해를 돕기 위하여", - novelAuthor = "이보라", - novelImage = "", - registeredDate = "2026.07.04", - ), + 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 index 4bdfd5739..6df03b146 100644 --- 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 @@ -62,12 +62,23 @@ fun NovelNotificationSubscriptionsContainer( } } +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 = emptyList(), + subscriptions = previewSubscriptions, selectedNovelIds = emptySet(), isEditing = false, isLoadable = false, @@ -77,3 +88,20 @@ private fun NovelNotificationSubscriptionsContainerPreview() { ) } } + +@Preview +@Composable +private fun NovelNotificationSubscriptionsContainerEditingPreview() { + WebsosoTheme { + NovelNotificationSubscriptionsContainer( + subscriptions = previewSubscriptions, + selectedNovelIds = setOf(0L, 2L), + isEditing = true, + isLoadable = false, + updateSubscriptions = {}, + onSubscriptionClick = {}, + onSubscriptionSelect = {}, + ) + } +} + From 9bcf2c9f16d44e0e7d066d0488582f9720410741 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:05:17 +0900 Subject: [PATCH 16/27] =?UTF-8?q?fix:=20=EC=9E=91=ED=92=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=9D=84=20=EB=88=8C=EB=9F=AC=EB=8F=84=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=B2=98=EB=A6=AC=EA=B0=80=20=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 푸시는 NovelDetailActivity에 novelId만 전달하고, 인앱 알림 목록도 로컬 상태만 갱신해 서버 읽음 처리 API가 호출되지 않았습니다. 피드와 동일하게 notificationId를 상세 화면까지 전달해 읽음 처리하도록 맞췄습니다. - NovelDetailActivity.getIntent에 notificationId 추가(기본값이 있어 기존 호출부는 그대로) - NovelDetailViewModel에서 조회 성공 시 읽음 처리 요청 - FCM NOVEL 분기와 알림 목록 진입 양쪽에서 notificationId 전달 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885435009 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../util/message/WSSFirebaseMessagingService.kt | 1 + .../websoso/ui/notification/NotificationActivity.kt | 2 +- .../websoso/ui/novelDetail/NovelDetailActivity.kt | 10 ++++++++++ .../websoso/ui/novelDetail/NovelDetailViewModel.kt | 13 +++++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) 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 df47ecd90..a55d6be65 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 @@ -62,6 +62,7 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() { PushDestination.NOVEL -> NovelDetailActivity.getIntent( this, requireNotNull(pushMessage.novelId), + notificationId, ) PushDestination.NOTIFICATION_DETAIL -> NotificationDetailActivity.getIntent( 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 b74cc43af..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 @@ -115,7 +115,7 @@ class NotificationActivity : AppCompatActivity() { private fun navigateToNovelDetail(notification: NotificationModel) { notificationViewModel.updateReadNotification(notification.id) - startActivity(NovelDetailActivity.getIntent(this, notification.intrinsicId)) + startActivity(NovelDetailActivity.getIntent(this, notification.intrinsicId, notification.id)) } companion object { diff --git a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt index 1fb293c52..4f68f1e20 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelDetailActivity.kt @@ -47,6 +47,7 @@ import com.into.websoso.ui.common.dialog.LoginRequestDialogFragment import com.into.websoso.ui.createFeed.CreateFeedActivity import com.into.websoso.ui.feedDetail.model.EditFeedModel import com.into.websoso.ui.normalExplore.NormalExploreActivity +import com.into.websoso.ui.novelDetail.NovelDetailViewModel.Companion.DEFAULT_NOTIFICATION_ID import com.into.websoso.ui.novelDetail.adapter.NovelDetailPagerAdapter import com.into.websoso.ui.novelDetail.model.NovelAlertModel import com.into.websoso.ui.novelFeed.NovelFeedViewModel @@ -109,10 +110,16 @@ class NovelDetailActivity : 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 @@ -428,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/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 + } } From be6f601ace41bbdf5d301850db819bba2891950d Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:05:31 +0900 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20=ED=8E=B8=EC=A7=91=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EC=95=B1=EB=B0=94=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0=EA=B0=80=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EB=B0=94=EB=A1=9C=20=EC=A2=85=EB=A3=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시스템 뒤로가기는 편집 모드만 해제하는데 앱바 뒤로가기는 Activity를 종료해 동작이 진입 방식에 따라 달랐습니다. 같은 뒤로가기 액션이므로 분기 로직을 하나의 람다로 공유하도록 했습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885430445 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../ui/novelNotification/NovelNotificationListScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index b8114b2ee..c70935583 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt @@ -30,13 +30,16 @@ fun NovelNotificationListScreen( ) { val uiState by viewModel.novelNotificationListUiState.collectAsStateWithLifecycle() - BackHandler { + // 시스템 뒤로가기와 앱바 뒤로가기는 같은 동작이므로 편집 모드 해제 분기를 공유한다 + val onBackClick: () -> Unit = { when (uiState.isEditing) { true -> viewModel.updateEditing(false) false -> onBackButtonClick() } } + BackHandler { onBackClick() } + NovelNotificationListScreen( uiState = uiState, updateSubscriptions = viewModel::updateSubscriptions, @@ -47,7 +50,7 @@ fun NovelNotificationListScreen( onDeleteDialogConfirmClick = viewModel::deleteSelectedSubscriptions, onSubscriptionClick = onSubscriptionClick, onExploreClick = onExploreClick, - onBackButtonClick = onBackButtonClick, + onBackButtonClick = onBackClick, ) } From b7a649c531039d006773ca5494cb5683a8c065ac Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:05:45 +0900 Subject: [PATCH 18/27] =?UTF-8?q?fix:=20=EC=B7=A8=EC=86=8C=EB=90=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=B4=20API=20=EC=98=A4=EB=A5=98=EB=A1=9C?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=EB=90=98=EC=96=B4=20UI=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EA=B0=80=20=EB=90=98=EB=8F=8C=EC=95=84=EA=B0=80?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CancellationException은 Exception 하위라 catch (e: Exception)에 걸려 Result.failure로 변환되고 있었습니다. 토글 디바운스로 이전 저장 작업을 취소하면 취소된 작업의 onFailure가 실행돼 사용자가 방금 바꾼 값을 이전 상태로 되돌릴 수 있었습니다. 작품 알림 유스케이스 4개 모두 취소는 Result로 감싸지 않고 전파하도록 했습니다. 회귀 테스트로 가드를 제거하면 실패하는 것을 확인했습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885412394 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- ...teNovelNotificationSubscriptionsUseCase.kt | 4 + .../GetNovelNotificationSettingUseCase.kt | 4 + ...etNovelNotificationSubscriptionsUseCase.kt | 4 + .../UpdateNovelNotificationSettingUseCase.kt | 4 + ...dateNovelNotificationSettingUseCaseTest.kt | 80 +++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt 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 index 8ca6ac053..185895349 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt @@ -3,6 +3,7 @@ package com.into.websoso.domain.usecase import com.into.websoso.data.repository.NovelNotificationRepository import com.into.websoso.domain.model.NovelNotificationType import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException class DeleteNovelNotificationSubscriptionsUseCase @Inject @@ -21,6 +22,9 @@ class DeleteNovelNotificationSubscriptionsUseCase ) } Result.success(Unit) + } catch (e: CancellationException) { + // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 + throw e } catch (e: Exception) { Result.failure(e) } 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 index 77ca0484d..ccf43ce20 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt @@ -4,6 +4,7 @@ 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 @@ -15,6 +16,9 @@ class GetNovelNotificationSettingUseCase Result.success( novelNotificationRepository.fetchNovelNotificationSetting(novelId).toDomain(), ) + } catch (e: CancellationException) { + // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 + 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 index 1dac78ab1..f70e8cc95 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt @@ -6,6 +6,7 @@ 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 @@ -28,6 +29,9 @@ class GetNovelNotificationSubscriptionsUseCase size = size, ).toDomain() Result.success(subscriptions) + } catch (e: CancellationException) { + // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 + throw e } catch (e: Exception) { Result.failure(e) } 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 index 4eb8f679d..45e419b67 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt @@ -3,6 +3,7 @@ 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 @@ -20,6 +21,9 @@ class UpdateNovelNotificationSettingUseCase isHiatusReturnNotificationEnabled = novelNotificationSetting.isHiatusReturnNotificationEnabled, ) Result.success(Unit) + } catch (e: CancellationException) { + // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 + throw e } catch (e: Exception) { Result.failure(e) } 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..7cf569867 --- /dev/null +++ b/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt @@ -0,0 +1,80 @@ +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 { + // 저장 요청이 취소되면 ViewModel의 onFailure가 실행돼 사용자가 방금 바꾼 토글이 되돌아가므로, + // 취소는 Result.failure가 아니라 예외 전파로 남아야 한다 + @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 + } +} From 82a58a87e91bd83ff1b60e42f8d259e87705b6de Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:07:24 +0900 Subject: [PATCH 19/27] =?UTF-8?q?fix:=20=ED=86=A0=EA=B8=80=20=EC=A7=81?= =?UTF-8?q?=ED=9B=84=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=AB=EC=9C=BC=EB=A9=B4=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20?= =?UTF-8?q?=EC=9C=A0=EC=8B=A4=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewModel이 프래그먼트 스코프라 시트를 닫으면 viewModelScope가 취소되고, 디바운스 대기 중이거나 전송 중이던 저장 요청이 함께 사라졌습니다. 화면에는 토글이 바뀐 것처럼 보이지만 서버에는 반영되지 않았습니다. 저장만 ViewModel 생명주기와 분리된 스코프에서 실행하도록 했습니다. 조회는 화면이 사라지면 취소되는 것이 맞아 viewModelScope에 그대로 뒀습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885231303 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../websoso/ui/novelDetail/NovelNotificationViewModel.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index 56506ba6c..be8c950b8 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt @@ -7,7 +7,10 @@ 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 @@ -28,6 +31,10 @@ class NovelNotificationViewModel private var syncedNovelNotificationSetting: NovelNotificationSetting = NovelNotificationSetting() private var saveJob: Job? = null + // 토글 직후 바텀시트를 닫으면 viewModelScope가 취소돼 저장이 유실되므로, + // 저장만은 ViewModel 생명주기와 분리된 스코프에서 실행한다 (onCleared에서 취소하지 않는다) + private val saveScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + fun updateNovelNotificationSetting(novelId: Long) { viewModelScope.launch { getNovelNotificationSettingUseCase(novelId) @@ -76,7 +83,7 @@ class NovelNotificationViewModel private fun saveNovelNotificationSetting(novelId: Long) { saveJob?.cancel() - saveJob = viewModelScope.launch { + saveJob = saveScope.launch { delay(SAVE_DEBOUNCE_MILLIS) val requestedNovelNotificationSetting = NovelNotificationSetting( From 18be37c6a8d95edcb8d36f8683f5ca56b6a0bd0c Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:07:44 +0900 Subject: [PATCH 20/27] =?UTF-8?q?refactor:=20=EC=9E=91=ED=92=88=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20Compose=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완전 신규 UI라 XML로 둘 이유가 없어 ComposeView로 옮겼습니다. DataBinding으로 isChecked/isEnabled/alpha를 수동 갱신하던 코드가 uiState 기반 선언형으로 정리됩니다. - dialog_novel_notification.xml 제거, BottomSheetDialogFragment + ComposeView로 전환 - SwitchCompat의 커스텀 thumb/track 드로어블을 동일한 치수(48x24dp)의 컴포저블로 대체 - 상태별 프리뷰 추가(전체 해제/일부 켬/전체 켬/오류) 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885369236 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../NovelNotificationBottomSheetDialog.kt | 91 ++++---- .../component/NovelNotificationContent.kt | 221 ++++++++++++++++++ .../res/layout/dialog_novel_notification.xml | 114 --------- 3 files changed, 269 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/ui/novelDetail/component/NovelNotificationContent.kt delete mode 100644 app/src/main/res/layout/dialog_novel_notification.xml 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 index 602bb63a3..fc5cc4de3 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt @@ -1,71 +1,76 @@ 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.common.ui.base.BaseBottomSheetDialog -import com.into.websoso.core.common.util.collectWithLifecycle -import com.into.websoso.databinding.DialogNovelNotificationBinding -import com.into.websoso.ui.novelDetail.model.NovelNotificationUiState +import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.ui.novelDetail.component.NovelNotificationContent import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class NovelNotificationBottomSheetDialog : BaseBottomSheetDialog(R.layout.dialog_novel_notification) { +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(), + ) + }, + ) + } + } + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - bindClickListener() - setupObserver() novelNotificationViewModel.updateNovelNotificationSetting(novelId) } - private fun bindClickListener() { - binding.onCompletionToggleClick = { - novelNotificationViewModel.updateCompletionNotificationEnabled( - novelId = novelId, - isEnabled = binding.scNovelNotificationCompletionToggle.isChecked.not(), - ) - } - binding.onHiatusReturnToggleClick = { - novelNotificationViewModel.updateHiatusReturnNotificationEnabled( - novelId = novelId, - isEnabled = binding.scNovelNotificationHiatusReturnToggle.isChecked.not(), - ) - } - binding.lifecycleOwner = viewLifecycleOwner - } - - private fun setupObserver() { - novelNotificationViewModel.novelNotificationUiState.collectWithLifecycle(viewLifecycleOwner) { uiState -> - updateToggleState(uiState) - } - } - - private fun updateToggleState(uiState: NovelNotificationUiState) { - binding.scNovelNotificationCompletionToggle.isChecked = uiState.isCompletionNotificationEnabled - binding.scNovelNotificationHiatusReturnToggle.isChecked = uiState.isHiatusReturnNotificationEnabled - - binding.clNovelNotificationCompletion.isEnabled = uiState.isEditable - binding.clNovelNotificationHiatusReturn.isEnabled = uiState.isEditable - - val contentAlpha = if (uiState.isError) DISABLED_ALPHA else DEFAULT_ALPHA - binding.clNovelNotificationCompletion.alpha = contentAlpha - binding.clNovelNotificationHiatusReturn.alpha = contentAlpha - } - companion object { const val NOVEL_NOTIFICATION_BOTTOM_SHEET_TAG = "NovelNotificationBottomSheetDialog" private const val NOVEL_ID = "NOVEL_ID" private const val DEFAULT_NOVEL_ID = 0L - private const val DEFAULT_ALPHA = 1f - private const val DISABLED_ALPHA = 0.4f fun newInstance(novelId: Long): NovelNotificationBottomSheetDialog = NovelNotificationBottomSheetDialog().apply { 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..93a8de9f9 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/component/NovelNotificationContent.kt @@ -0,0 +1,221 @@ +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.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.ui.novelDetail.model.NovelNotificationUiState + +@Composable +fun NovelNotificationContent( + uiState: NovelNotificationUiState, + onCompletionToggleClick: () -> Unit, + onHiatusReturnToggleClick: () -> 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), + ) + } +} + +@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 = {}, + ) + } +} + +@Preview +@Composable +private fun NovelNotificationContentPartiallyEnabledPreview() { + WebsosoTheme { + NovelNotificationContent( + uiState = NovelNotificationUiState( + isLoading = false, + isCompletionNotificationEnabled = true, + ), + onCompletionToggleClick = {}, + onHiatusReturnToggleClick = {}, + ) + } +} + +@Preview +@Composable +private fun NovelNotificationContentEnabledPreview() { + WebsosoTheme { + NovelNotificationContent( + uiState = NovelNotificationUiState( + isLoading = false, + isCompletionNotificationEnabled = true, + isHiatusReturnNotificationEnabled = true, + ), + onCompletionToggleClick = {}, + onHiatusReturnToggleClick = {}, + ) + } +} + +@Preview +@Composable +private fun NovelNotificationContentErrorPreview() { + WebsosoTheme { + NovelNotificationContent( + uiState = NovelNotificationUiState( + isLoading = false, + isError = true, + ), + onCompletionToggleClick = {}, + onHiatusReturnToggleClick = {}, + ) + } +} diff --git a/app/src/main/res/layout/dialog_novel_notification.xml b/app/src/main/res/layout/dialog_novel_notification.xml deleted file mode 100644 index 40f5cef7a..000000000 --- a/app/src/main/res/layout/dialog_novel_notification.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 407551b979c8c1129f5d6db58aa8acdcb60106b0 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:08:01 +0900 Subject: [PATCH 21/27] =?UTF-8?q?feat:=20=EC=9E=91=ED=92=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초기 조회에 실패하면 토글이 잠기기만 하고 시트를 닫았다 다시 여는 것 말고는 빠져나갈 방법이 없었습니다. 토글 아래에 실패 안내와 다시 시도를 노출하고, 재시도 시 로딩/오류 상태를 되돌리도록 했습니다. 초기 조회 전과 오류 상태에서 저장을 막는 isEditable 가드는 이미 있어 그대로 두었습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3873294629 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../NovelNotificationBottomSheetDialog.kt | 3 ++ .../novelDetail/NovelNotificationViewModel.kt | 5 +++ .../component/NovelNotificationContent.kt | 38 +++++++++++++++++++ core/resource/src/main/res/values/strings.xml | 2 + 4 files changed, 48 insertions(+) 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 index fc5cc4de3..430431172 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationBottomSheetDialog.kt @@ -53,6 +53,9 @@ class NovelNotificationBottomSheetDialog : BottomSheetDialogFragment() { isEnabled = uiState.isHiatusReturnNotificationEnabled.not(), ) }, + onRetryClick = { + novelNotificationViewModel.updateNovelNotificationSetting(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 index be8c950b8..e93dfb3f3 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt @@ -36,6 +36,11 @@ class NovelNotificationViewModel 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 -> 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 index 93a8de9f9..2d2a45f72 100644 --- 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 @@ -26,6 +26,7 @@ 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 @@ -36,6 +37,8 @@ import com.into.websoso.core.resource.R.string.novel_notification_completion_des 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 @@ -43,6 +46,7 @@ fun NovelNotificationContent( uiState: NovelNotificationUiState, onCompletionToggleClick: () -> Unit, onHiatusReturnToggleClick: () -> Unit, + onRetryClick: () -> Unit, modifier: Modifier = Modifier, ) { val contentAlpha = when (uiState.isError) { @@ -76,6 +80,36 @@ fun NovelNotificationContent( 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), + ) } } @@ -170,6 +204,7 @@ private fun NovelNotificationContentPreview() { uiState = NovelNotificationUiState(isLoading = false), onCompletionToggleClick = {}, onHiatusReturnToggleClick = {}, + onRetryClick = {}, ) } } @@ -185,6 +220,7 @@ private fun NovelNotificationContentPartiallyEnabledPreview() { ), onCompletionToggleClick = {}, onHiatusReturnToggleClick = {}, + onRetryClick = {}, ) } } @@ -201,6 +237,7 @@ private fun NovelNotificationContentEnabledPreview() { ), onCompletionToggleClick = {}, onHiatusReturnToggleClick = {}, + onRetryClick = {}, ) } } @@ -216,6 +253,7 @@ private fun NovelNotificationContentErrorPreview() { ), onCompletionToggleClick = {}, onHiatusReturnToggleClick = {}, + onRetryClick = {}, ) } } diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 42c8c5417..b79c3d12d 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -317,6 +317,8 @@ %1$s 외 %2$d작품 취소 삭제 + 알림 설정을 불러오지 못했어요 + 다시 시도 앱 알림이 꺼져있어요 From 0358c8f05ce066af3d8f0f71713c971d08064287 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:08:15 +0900 Subject: [PATCH 22/27] =?UTF-8?q?fix:=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EB=B9=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조회에 실패해도 isInitialLoaded가 true가 되고 subscriptions는 비어 있어 isEmpty가 참이 됐습니다. 화면은 isError를 읽지 않아 네트워크 오류가 "알림 등록한 작품이 없어요"로 표시되고 재시도 경로도 사라졌습니다. - isEmpty에서 오류와 로딩 상태를 제외(재시도 중 빈 화면이 깜빡이던 것도 함께 해결) - 앱의 기존 오류 UI(layout_loading의 실패 상태)를 Compose로 옮겨 오류 화면 추가 - 앱바 액션 노출 조건을 subscriptions.isNotEmpty()로 변경 - 상태 파생 로직 테스트 추가 피그마에 이 화면 전용 오류 시안이 없어 앱 공통 오류 UI의 이미지와 문구를 그대로 재사용했습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885251515 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../NovelNotificationListScreen.kt | 17 +++- .../component/NovelNotificationErrorView.kt | 82 +++++++++++++++++++ .../model/NovelNotificationListUiState.kt | 9 +- .../model/NovelNotificationListUiStateTest.kt | 33 ++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationErrorView.kt 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 index c70935583..f388379b0 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt @@ -16,6 +16,7 @@ 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 @@ -77,12 +78,14 @@ private fun NovelNotificationListScreen( notificationType = uiState.notificationType, isEditing = uiState.isEditing, isDeletable = uiState.isDeletable, - isActionVisible = uiState.isEmpty.not(), + isActionVisible = uiState.isActionVisible, onBackButtonClick = onBackButtonClick, onEditButtonClick = onEditButtonClick, onDeleteButtonClick = onDeleteButtonClick, ) when { + uiState.isErrorVisible -> NovelNotificationErrorView(onReloadClick = updateSubscriptions) + uiState.isEmpty -> NovelNotificationEmptyView(onExploreClick = onExploreClick) else -> NovelNotificationSubscriptionsContainer( @@ -172,6 +175,18 @@ private fun NovelNotificationListScreenEmptyPreview() { ) } +@Preview +@Composable +private fun NovelNotificationListScreenErrorPreview() { + NovelNotificationListScreenPreviewContent( + uiState = NovelNotificationListUiState( + isInitialLoaded = true, + isLoadable = false, + isError = true, + ), + ) +} + @Preview @Composable private fun NovelNotificationListScreenDeleteDialogPreview() { 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/model/NovelNotificationListUiState.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/model/NovelNotificationListUiState.kt index ef8c41abc..ba41f2410 100644 --- 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 @@ -16,7 +16,14 @@ data class NovelNotificationListUiState( val selectedNovelIds: Set = emptySet(), val subscriptions: List = emptyList(), ) { - val isEmpty: Boolean get() = isInitialLoaded && subscriptions.isEmpty() + // 조회 실패도 isInitialLoaded를 true로 만들기 때문에, 오류와 로딩을 빼지 않으면 + // 네트워크 오류나 재시도 중에 '등록한 작품이 없어요'가 잘못 노출된다 + 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() 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 index 1891f9016..ca53c9287 100644 --- 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 @@ -20,6 +20,39 @@ class NovelNotificationListUiStateTest { 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))) From 79bc8692a164d3b580ff574e96f6795b58a35863 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:08:29 +0900 Subject: [PATCH 23/27] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A4=91=20=EC=82=AD=EC=A0=9C=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20=ED=9B=84=EC=86=8D=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로드된 항목을 모두 삭제했을 때 다음 페이지를 이어 불러오는 호출이 진행 중인 요청 때문에 isLoading 가드에 막혀 사라지고 있었습니다. 진행 중인 요청이 있으면 재조회를 예약해 두고, 그 요청이 성공한 뒤 목록이 여전히 비어 있을 때만 이어서 불러옵니다. 예약 플래그는 삭제 경로에서만 세워 스크롤 감지가 페이지를 더 당겨오지 않도록 했고, 실패 시에는 예약을 버리고 오류 화면의 재시도에 맡깁니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3874751772 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../NovelNotificationListViewModel.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 index df464e8bc..a53ebcc43 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt @@ -32,6 +32,10 @@ class NovelNotificationListViewModel MutableStateFlow(NovelNotificationListUiState(notificationType = notificationType)) val novelNotificationListUiState: StateFlow get() = _novelNotificationListUiState + // 삭제로 목록이 비었는데 페이지 요청이 진행 중이면 isLoading 가드에 막혀 후속 조회가 사라지므로, + // 진행 중인 요청이 끝난 뒤 이어서 조회하도록 예약해 둔다 + private var isRefetchPending = false + init { updateSubscriptions() } @@ -68,9 +72,21 @@ class NovelNotificationListViewModel 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, @@ -129,6 +145,11 @@ class NovelNotificationListViewModel ) // 로드된 항목을 모두 삭제해도 다음 페이지가 남아 있으면 빈 화면 대신 이어서 불러온다 - if (remainedSubscriptions.isEmpty() && currentUiState.isLoadable) updateSubscriptions() + if (remainedSubscriptions.isEmpty() && currentUiState.isLoadable) { + when (currentUiState.isLoading) { + true -> isRefetchPending = true + false -> updateSubscriptions() + } + } } } From d4e930ecf98ef5ce1afda2b236997a561a70617d Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:08:41 +0900 Subject: [PATCH 24/27] =?UTF-8?q?fix:=20100=EA=B0=9C=20=EC=B4=88=EA=B3=BC?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=EC=9D=B4=20=EC=A0=84=EC=B2=B4=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A1=9C=20=EC=B2=98=EB=A6=AC=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 삭제 요청을 100개씩 나눠 순차 실행하므로 앞쪽 요청만 성공한 채 실패할 수 있는데, 전체를 failure로 반환해 이미 서버에서 지워진 작품이 화면에 계속 남았습니다. 성공한 chunk를 누적해 실제로 삭제된 목록을 함께 돌려주고, ViewModel이 그만큼만 목록에서 제거하도록 했습니다. 하나도 지우지 못한 경우는 기존과 같이 실패로 다룹니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885260752 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../model/NovelNotificationDeleteResult.kt | 10 +++ ...teNovelNotificationSubscriptionsUseCase.kt | 28 ++++++- .../NovelNotificationListViewModel.kt | 5 +- ...velNotificationSubscriptionsUseCaseTest.kt | 84 +++++++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/domain/model/NovelNotificationDeleteResult.kt create mode 100644 app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt 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/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt index 185895349..367a2b7d9 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt @@ -1,6 +1,7 @@ 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 @@ -13,21 +14,40 @@ class DeleteNovelNotificationSubscriptionsUseCase suspend operator fun invoke( notificationType: NovelNotificationType, novelIds: List, - ): Result = - try { + ): Result { + val deletedNovelIds = mutableListOf() + + return try { novelIds.chunked(MAX_DELETABLE_SIZE).forEach { chunkedNovelIds -> novelNotificationRepository.deleteNovelNotificationSubscriptions( notificationType = notificationType.name, novelIds = chunkedNovelIds, ) + deletedNovelIds += chunkedNovelIds } - Result.success(Unit) + Result.success( + NovelNotificationDeleteResult( + deletedNovelIds = deletedNovelIds, + isCompleted = true, + ), + ) } catch (e: CancellationException) { // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 throw e } catch (e: Exception) { - Result.failure(e) + 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/ui/novelNotification/NovelNotificationListViewModel.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt index a53ebcc43..da159a88b 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt @@ -125,8 +125,9 @@ class NovelNotificationListViewModel deleteNovelNotificationSubscriptionsUseCase( notificationType = notificationType, novelIds = selectedNovelIds.toList(), - ).onSuccess { - handleDeleteSuccessState(selectedNovelIds) + ).onSuccess { novelNotificationDeleteResult -> + // 일부만 삭제된 경우에도 서버에 반영된 만큼은 목록에서 지운다 + handleDeleteSuccessState(novelNotificationDeleteResult.deletedNovelIds.toSet()) }.onFailure { updateDeleteDialogVisibility(false) } diff --git a/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt b/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt new file mode 100644 index 000000000..37e38f104 --- /dev/null +++ b/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt @@ -0,0 +1,84 @@ +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.NovelNotificationType.COMPLETION +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class DeleteNovelNotificationSubscriptionsUseCaseTest { + @Test + fun `모두 삭제되면 완료로 표시하고 요청한 목록을 그대로 돌려준다`() { + val api = FakeNovelNotificationApi(failFromCall = null) + val useCase = DeleteNovelNotificationSubscriptionsUseCase(NovelNotificationRepository(api)) + + val result = runBlocking { useCase(COMPLETION, novelIds(150)) } + + val deleteResult = result.getOrThrow() + assertTrue(deleteResult.isCompleted) + assertEquals(150, deleteResult.deletedNovelIds.size) + assertEquals(2, api.deleteCallCount) + } + + // 앞쪽 100개가 이미 서버에서 지워졌는데 전체를 실패로 돌리면 화면에 계속 남는다 + @Test + fun `두 번째 요청이 실패하면 먼저 삭제된 항목만 돌려준다`() { + val api = FakeNovelNotificationApi(failFromCall = 2) + val useCase = DeleteNovelNotificationSubscriptionsUseCase(NovelNotificationRepository(api)) + + val result = runBlocking { useCase(COMPLETION, novelIds(150)) } + + val deleteResult = result.getOrThrow() + assertFalse(deleteResult.isCompleted) + assertEquals(100, deleteResult.deletedNovelIds.size) + assertEquals((1L..100L).toList(), deleteResult.deletedNovelIds) + } + + @Test + fun `첫 요청부터 실패하면 실패로 돌려준다`() { + val api = FakeNovelNotificationApi(failFromCall = 1) + val useCase = DeleteNovelNotificationSubscriptionsUseCase(NovelNotificationRepository(api)) + + val result = runBlocking { useCase(COMPLETION, novelIds(150)) } + + assertTrue(result.isFailure) + } + + private fun novelIds(size: Int): List = (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() + } +} From 704739cff387b35b6f7ffceb65ae815f50725f25 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:08:54 +0900 Subject: [PATCH 25/27] =?UTF-8?q?refactor:=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=20=EB=A6=AC=EC=86=8C=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본 제목/내용과 알림 채널 이름/설명이 코드에 하드코딩돼 있었습니다. 모두 사용자에게 노출되는 문구라 리소스로 옮겼습니다. PushMessage는 Context가 없는 파싱 모델이라 title/body를 nullable로 두고 기본값은 표시 시점에 채웁니다. "웹소소"는 새 문자열을 만들지 않고 기존 app_name을 재사용했습니다. 리뷰: https://github.com/Team-WSS/WSS-Android/pull/950#discussion_r3885321121 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../core/common/util/message/PushMessage.kt | 11 ++++------- .../util/message/WSSFirebaseMessagingService.kt | 15 ++++++++++----- .../core/common/util/message/PushMessageTest.kt | 7 ++++--- core/resource/src/main/res/values/strings.xml | 4 ++++ 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt index b76d2822a..1921f1f9a 100644 --- a/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt +++ b/app/src/main/java/com/into/websoso/core/common/util/message/PushMessage.kt @@ -1,8 +1,8 @@ package com.into.websoso.core.common.util.message data class PushMessage( - val title: String, - val body: String, + val title: String?, + val body: String?, val feedId: Long?, val novelId: Long?, val notificationId: Long, @@ -15,15 +15,12 @@ data class PushMessage( } companion object { - const val DEFAULT_TITLE = "웹소소" - const val DEFAULT_BODY = "푸시 알림 메시지입니다" - fun from(data: Map): PushMessage? { val notificationId = data["notificationId"]?.toLongOrNull() ?: return null return PushMessage( - title = data["title"] ?: DEFAULT_TITLE, - body = data["body"] ?: DEFAULT_BODY, + title = data["title"], + body = data["body"], feedId = data["feedId"]?.toLongOrNull(), novelId = data["novelId"]?.toLongOrNull(), notificationId = notificationId, 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 a55d6be65..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,6 +9,9 @@ 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 @@ -32,7 +35,11 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() { setupNotificationChannel() val pendingIntent = createPendingIntent(pushMessage) - showNotification(pushMessage.title, pushMessage.body, pendingIntent) + showNotification( + title = pushMessage.title ?: getString(app_name), + body = pushMessage.body ?: getString(push_notification_default_body), + pendingIntent = pendingIntent, + ) } private fun setupNotificationChannel() { @@ -40,10 +47,10 @@ 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) } @@ -113,7 +120,5 @@ class WSSFirebaseMessagingService : FirebaseMessagingService() { companion object { private const val CHANNEL_ID = "websoso" - private const val CHANNEL_NAME = "웹소소" - private const val CHANNEL_DESCRIPTION = "웹소소 알림입니다." } } diff --git a/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt b/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt index 6c78ea1d0..92e815a5a 100644 --- a/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt +++ b/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt @@ -75,12 +75,13 @@ class PushMessageTest { assertNull(pushMessage) } + // 기본 문구는 문자열 리소스라 표시 시점에 채우고, 파싱 단계에서는 없음을 그대로 남긴다 @Test - fun `제목과 내용이 없으면 기본값을 사용한다`() { + fun `제목과 내용이 없으면 비워 둔다`() { val pushMessage = PushMessage.from(mapOf("notificationId" to "1")) - assertEquals(PushMessage.DEFAULT_TITLE, pushMessage?.title) - assertEquals(PushMessage.DEFAULT_BODY, pushMessage?.body) + assertNull(pushMessage?.title) + assertNull(pushMessage?.body) } companion object { diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index b79c3d12d..7a4d78bc7 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -320,6 +320,10 @@ 알림 설정을 불러오지 못했어요 다시 시도 + + 웹소소 알림입니다. + 푸시 알림 메시지입니다 + 앱 알림이 꺼져있어요 중요한 소식만 전해드릴게요!\n기기 설정에서 알림을 허용해주세요. From a5bfe8991eb59eb64eae8dd50205c8b015af2a95 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:26:39 +0900 Subject: [PATCH 26/27] =?UTF-8?q?chore:=20=EC=9E=91=ED=92=88=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B4=80=EB=A0=A8=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../usecase/DeleteNovelNotificationSubscriptionsUseCase.kt | 3 --- .../domain/usecase/GetNovelNotificationSettingUseCase.kt | 1 - .../usecase/GetNovelNotificationSubscriptionsUseCase.kt | 1 - .../domain/usecase/UpdateNovelNotificationSettingUseCase.kt | 1 - .../into/websoso/ui/novelDetail/NovelNotificationViewModel.kt | 2 -- .../ui/novelDetail/component/NovelNotificationContent.kt | 1 - .../ui/novelNotification/NovelNotificationListScreen.kt | 1 - .../ui/novelNotification/NovelNotificationListViewModel.kt | 4 ---- .../novelNotification/model/NovelNotificationListUiState.kt | 3 --- .../into/websoso/core/common/util/message/PushMessageTest.kt | 1 - .../DeleteNovelNotificationSubscriptionsUseCaseTest.kt | 1 - .../usecase/UpdateNovelNotificationSettingUseCaseTest.kt | 2 -- 12 files changed, 21 deletions(-) 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 index 367a2b7d9..31ffa6461 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt @@ -32,13 +32,10 @@ class DeleteNovelNotificationSubscriptionsUseCase ), ) } catch (e: CancellationException) { - // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 throw e } catch (e: Exception) { when (deletedNovelIds.isEmpty()) { - // 하나도 지우지 못했으면 기존과 같이 실패로 다룬다 true -> Result.failure(e) - // 앞쪽 요청이 이미 서버에 반영됐으므로 그만큼은 화면에서도 지워야 한다 false -> Result.success( NovelNotificationDeleteResult( deletedNovelIds = deletedNovelIds, 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 index ccf43ce20..b2b72cbf2 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSettingUseCase.kt @@ -17,7 +17,6 @@ class GetNovelNotificationSettingUseCase novelNotificationRepository.fetchNovelNotificationSetting(novelId).toDomain(), ) } catch (e: CancellationException) { - // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 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 index f70e8cc95..61154877c 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/GetNovelNotificationSubscriptionsUseCase.kt @@ -30,7 +30,6 @@ class GetNovelNotificationSubscriptionsUseCase ).toDomain() Result.success(subscriptions) } catch (e: CancellationException) { - // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 throw e } catch (e: Exception) { Result.failure(e) 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 index 45e419b67..ab84c1f00 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCase.kt @@ -22,7 +22,6 @@ class UpdateNovelNotificationSettingUseCase ) Result.success(Unit) } catch (e: CancellationException) { - // 취소는 실패가 아니므로 Result로 감싸지 않고 그대로 전파한다 throw e } catch (e: Exception) { Result.failure(e) 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 index e93dfb3f3..04424f234 100644 --- a/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelDetail/NovelNotificationViewModel.kt @@ -31,8 +31,6 @@ class NovelNotificationViewModel private var syncedNovelNotificationSetting: NovelNotificationSetting = NovelNotificationSetting() private var saveJob: Job? = null - // 토글 직후 바텀시트를 닫으면 viewModelScope가 취소돼 저장이 유실되므로, - // 저장만은 ViewModel 생명주기와 분리된 스코프에서 실행한다 (onCleared에서 취소하지 않는다) private val saveScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) fun updateNovelNotificationSetting(novelId: Long) { 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 index 2d2a45f72..382cdd903 100644 --- 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 @@ -80,7 +80,6 @@ fun NovelNotificationContent( onClick = onHiatusReturnToggleClick, modifier = Modifier.alpha(contentAlpha), ) - // 조회에 실패하면 토글이 잠기기만 하고 빠져나갈 방법이 없으므로 재시도 경로를 준다 if (uiState.isError) { NovelNotificationRetryRow(onRetryClick = onRetryClick) } 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 index f388379b0..abb15dbda 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListScreen.kt @@ -31,7 +31,6 @@ fun NovelNotificationListScreen( ) { val uiState by viewModel.novelNotificationListUiState.collectAsStateWithLifecycle() - // 시스템 뒤로가기와 앱바 뒤로가기는 같은 동작이므로 편집 모드 해제 분기를 공유한다 val onBackClick: () -> Unit = { when (uiState.isEditing) { true -> viewModel.updateEditing(false) 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 index da159a88b..b653dc77a 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/NovelNotificationListViewModel.kt @@ -32,8 +32,6 @@ class NovelNotificationListViewModel MutableStateFlow(NovelNotificationListUiState(notificationType = notificationType)) val novelNotificationListUiState: StateFlow get() = _novelNotificationListUiState - // 삭제로 목록이 비었는데 페이지 요청이 진행 중이면 isLoading 가드에 막혀 후속 조회가 사라지므로, - // 진행 중인 요청이 끝난 뒤 이어서 조회하도록 예약해 둔다 private var isRefetchPending = false init { @@ -84,7 +82,6 @@ class NovelNotificationListViewModel } private fun handleFailureState() { - // 실패했을 때는 자동으로 다시 부르지 않고 오류 화면의 재시도 버튼에 맡긴다 isRefetchPending = false _novelNotificationListUiState.value = novelNotificationListUiState.value.copy( @@ -126,7 +123,6 @@ class NovelNotificationListViewModel notificationType = notificationType, novelIds = selectedNovelIds.toList(), ).onSuccess { novelNotificationDeleteResult -> - // 일부만 삭제된 경우에도 서버에 반영된 만큼은 목록에서 지운다 handleDeleteSuccessState(novelNotificationDeleteResult.deletedNovelIds.toSet()) }.onFailure { updateDeleteDialogVisibility(false) 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 index ba41f2410..37bd2512a 100644 --- 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 @@ -16,11 +16,8 @@ data class NovelNotificationListUiState( val selectedNovelIds: Set = emptySet(), val subscriptions: List = emptyList(), ) { - // 조회 실패도 isInitialLoaded를 true로 만들기 때문에, 오류와 로딩을 빼지 않으면 - // 네트워크 오류나 재시도 중에 '등록한 작품이 없어요'가 잘못 노출된다 val isEmpty: Boolean get() = isInitialLoaded && isLoading.not() && isError.not() && subscriptions.isEmpty() - // 이미 불러온 항목이 있으면 다음 페이지 실패로 목록을 지우지 않고 그대로 둔다 val isErrorVisible: Boolean get() = isError && subscriptions.isEmpty() val isActionVisible: Boolean get() = subscriptions.isNotEmpty() diff --git a/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt b/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt index 92e815a5a..2e2844d7c 100644 --- a/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt +++ b/app/src/test/java/com/into/websoso/core/common/util/message/PushMessageTest.kt @@ -75,7 +75,6 @@ class PushMessageTest { assertNull(pushMessage) } - // 기본 문구는 문자열 리소스라 표시 시점에 채우고, 파싱 단계에서는 없음을 그대로 남긴다 @Test fun `제목과 내용이 없으면 비워 둔다`() { val pushMessage = PushMessage.from(mapOf("notificationId" to "1")) diff --git a/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt b/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt index 37e38f104..c68071058 100644 --- a/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt +++ b/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt @@ -28,7 +28,6 @@ class DeleteNovelNotificationSubscriptionsUseCaseTest { assertEquals(2, api.deleteCallCount) } - // 앞쪽 100개가 이미 서버에서 지워졌는데 전체를 실패로 돌리면 화면에 계속 남는다 @Test fun `두 번째 요청이 실패하면 먼저 삭제된 항목만 돌려준다`() { val api = FakeNovelNotificationApi(failFromCall = 2) 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 index 7cf569867..7a6eec3c8 100644 --- a/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt +++ b/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt @@ -15,8 +15,6 @@ import java.io.IOException import kotlin.coroutines.cancellation.CancellationException class UpdateNovelNotificationSettingUseCaseTest { - // 저장 요청이 취소되면 ViewModel의 onFailure가 실행돼 사용자가 방금 바꾼 토글이 되돌아가므로, - // 취소는 Result.failure가 아니라 예외 전파로 남아야 한다 @Test fun `저장이 취소되면 CancellationException을 그대로 전파한다`() { val useCase = UpdateNovelNotificationSettingUseCase( From 1fc483e6a4885d8b2b44330e209f9bcc9ce1dcd8 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sat, 29 Aug 2026 17:42:13 +0900 Subject: [PATCH 27/27] =?UTF-8?q?fix:=20ktlint=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI는 standalone ktlint 1.8.0을 쓰는데 Gradle 플러그인과 적용 규칙이 달라 로컬 ktlintCheck에서는 걸리지 않던 항목들이 실패했습니다. - blank-line-between-when-conditions: 멀티라인 분기가 있는 when에 빈 줄 추가 - function-signature: 표현식 본문을 시그니처와 같은 줄로 정리 - no-consecutive-blank-lines: 파일 끝 불필요한 빈 줄 제거 ktlint 1.8.0으로 직접 검증했습니다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ps35jFYgVThoehEQ2L3tRx --- .../usecase/DeleteNovelNotificationSubscriptionsUseCase.kt | 1 + .../component/NovelNotificationSubscriptionsContainer.kt | 1 - .../usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt | 3 +-- .../usecase/UpdateNovelNotificationSettingUseCaseTest.kt | 3 +-- 4 files changed, 3 insertions(+), 5 deletions(-) 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 index 31ffa6461..f60bf8ad5 100644 --- a/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt +++ b/app/src/main/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCase.kt @@ -36,6 +36,7 @@ class DeleteNovelNotificationSubscriptionsUseCase } catch (e: Exception) { when (deletedNovelIds.isEmpty()) { true -> Result.failure(e) + false -> Result.success( NovelNotificationDeleteResult( deletedNovelIds = deletedNovelIds, 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 index 6df03b146..072bc76cf 100644 --- 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 @@ -104,4 +104,3 @@ private fun NovelNotificationSubscriptionsContainerEditingPreview() { ) } } - diff --git a/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt b/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt index c68071058..db3e138aa 100644 --- a/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt +++ b/app/src/test/java/com/into/websoso/domain/usecase/DeleteNovelNotificationSubscriptionsUseCaseTest.kt @@ -66,8 +66,7 @@ class DeleteNovelNotificationSubscriptionsUseCaseTest { if (failFromCall != null && deleteCallCount >= failFromCall) throw IOException() } - override suspend fun getNovelNotificationSetting(novelId: Long): NovelNotificationSettingResponseDto = - throw NotImplementedError() + override suspend fun getNovelNotificationSetting(novelId: Long): NovelNotificationSettingResponseDto = throw NotImplementedError() override suspend fun putNovelNotificationSetting( novelId: Long, 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 index 7a6eec3c8..a9ddd11b5 100644 --- a/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt +++ b/app/src/test/java/com/into/websoso/domain/usecase/UpdateNovelNotificationSettingUseCaseTest.kt @@ -51,8 +51,7 @@ class UpdateNovelNotificationSettingUseCaseTest { private class FakeNovelNotificationApi( private val throwable: Throwable?, ) : NovelNotificationApi { - override suspend fun getNovelNotificationSetting(novelId: Long): NovelNotificationSettingResponseDto = - throw NotImplementedError() + override suspend fun getNovelNotificationSetting(novelId: Long): NovelNotificationSettingResponseDto = throw NotImplementedError() override suspend fun putNovelNotificationSetting( novelId: Long,