From 70c7495d1e47ca5414264ac0a53aa094a3bbb68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 5 Jun 2026 21:28:11 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20AfterNoteListItem=20=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=ED=99=88=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도메인 모델 `AfterNoteListItemDto`를 `AfterNoteListItem`으로 이름을 변경하여 모델 성격 명확화 및 관련 의존성(Mapper, Repository, ViewModel 등) 수정 - `ReceiverHomeViewModel`에서 여러 `async` 작업을 `coroutineScope` 내에서 실행하도록 개선하여 구조화된 동시성(Structured Concurrency) 보장 - `AfternoteHostViewModel`의 패스키 등록 상태(`isPasskeyRegistered`) Flow 전략을 `Eagerly`에서 `WhileSubscribed(5_000)`로 변경하여 자원 효율화 - `ReceiverAfternotePagingSource` 및 `ReceiverRepository` 인터페이스의 반환 타입을 변경된 모델 명칭에 맞춰 업데이트 - `ReceiverAfternoteListItemDtoToDomainTest` 내 주석 및 참조 모델명 수정 --- .../screen/receiver/ReceiverHomeViewModel.kt | 95 ++++++++++--------- .../ReceiverAfternoteListItemDtoToDomain.kt | 8 +- .../paging/ReceiverAfternotePagingSource.kt | 8 +- .../receiver/ReceiverRepositoryImpl.kt | 4 +- ...eceiverAfternoteListItemDtoToDomainTest.kt | 2 +- .../model/receiver/ReceiverAfternoteModels.kt | 4 +- .../repository/receiver/ReceiverRepository.kt | 4 +- .../presentation/AfternoteHostViewModel.kt | 6 +- .../home/ReceiverAfternoteHomeViewModel.kt | 4 +- 9 files changed, 71 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModel.kt b/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModel.kt index 3a10ba14e..dffaa5e13 100644 --- a/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModel.kt +++ b/app/src/main/java/com/afternote/afternote_fe/screen/receiver/ReceiverHomeViewModel.kt @@ -7,12 +7,13 @@ import com.afternote.afternote_fe.screen.receiver.model.MindRecordSummary import com.afternote.afternote_fe.screen.receiver.model.ReceiverDownloadState import com.afternote.afternote_fe.screen.receiver.model.ReceiverHomeUiState import com.afternote.afternote_fe.screen.receiver.model.SenderMessage -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItemDto +import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.shared.util.getAfternoteDisplayRes import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,54 +53,56 @@ class ReceiverHomeViewModel private fun loadHome() { _uiState.value = ReceiverHomeUiState.Loading viewModelScope.launch { - val afternotes = async { receiverRepository.getReceivedAfterNotes() } - val mindRecords = async { receiverRepository.loadMindRecordsCount() } - val timeLetters = async { receiverRepository.loadTimeLettersCount() } - val message = async { receiverRepository.loadSenderMessage() } - val afternotesRes = afternotes.await() - val mindRecordsRes = mindRecords.await() - val timeLettersRes = timeLetters.await() - val messageRes = message.await() + coroutineScope { + val afternotes = async { receiverRepository.getReceivedAfterNotes() } + val mindRecords = async { receiverRepository.loadMindRecordsCount() } + val timeLetters = async { receiverRepository.loadTimeLettersCount() } + val message = async { receiverRepository.loadSenderMessage() } + val afternotesRes = afternotes.await() + val mindRecordsRes = mindRecords.await() + val timeLettersRes = timeLetters.await() + val messageRes = message.await() + + // 모든 호출이 실패한 경우만 Error. 일부 실패는 fallback 으로 진행. + if (afternotesRes.isFailure && + mindRecordsRes.isFailure && + timeLettersRes.isFailure && + messageRes.isFailure + ) { + _uiState.value = + ReceiverHomeUiState.Error( + afternotesRes.exceptionOrNull() ?: RuntimeException("All home requests failed"), + ) + return@coroutineScope + } + + val afternotesResult = + afternotesRes.getOrNull() ?: AfterNotesListResult(items = emptyList(), totalCount = 0) + val mindRecordsCount = mindRecordsRes.getOrNull()?.totalCount ?: 0 + val timeLettersCount = timeLettersRes.getOrNull()?.totalCount ?: 0 + val senderMessageInfo = messageRes.getOrNull() + // senderName / senderMessage 둘 다 blank 가드 — sender 가 이름·메시지 미입력 케이스 대응. + // ".orEmpty()" 만으로는 공백(" ") 통과해 "故 님이 남기신 기록" / "님의 한 마디" UI 깨짐. + val senderName = senderMessageInfo?.senderName?.takeIf { it.isNotBlank() }.orEmpty() + val senderMessageBody = senderMessageInfo?.message?.takeIf { it.isNotBlank() } - // 모든 호출이 실패한 경우만 Error. 일부 실패는 fallback 으로 진행. - if (afternotesRes.isFailure && - mindRecordsRes.isFailure && - timeLettersRes.isFailure && - messageRes.isFailure - ) { _uiState.value = - ReceiverHomeUiState.Error( - afternotesRes.exceptionOrNull() ?: RuntimeException("All home requests failed"), + ReceiverHomeUiState.Success( + senderName = senderName, + // TODO: 백엔드 응답에 date 필드 추가되면 채울 것. 현재 receiver-auth/message 응답은 senderName/message 만. + senderMessage = senderMessageBody?.let { SenderMessage(date = "", body = it) }, + mindRecord = + MindRecordSummary( + totalCount = mindRecordsCount, + dailyQuestionCount = 0, + diaryCount = 0, + deepThoughtCount = 0, + ), + timeLetterTotalCount = timeLettersCount, + afternoteTotalCount = afternotesResult.totalCount, + afternoteIcons = afternotesResult.items.toAfternoteIcons(), ) - return@launch } - - val afternotesResult = - afternotesRes.getOrNull() ?: AfterNotesListResult(items = emptyList(), totalCount = 0) - val mindRecordsCount = mindRecordsRes.getOrNull()?.totalCount ?: 0 - val timeLettersCount = timeLettersRes.getOrNull()?.totalCount ?: 0 - val senderMessageInfo = messageRes.getOrNull() - // senderName / senderMessage 둘 다 blank 가드 — sender 가 이름·메시지 미입력 케이스 대응. - // ".orEmpty()" 만으로는 공백(" ") 통과해 "故 님이 남기신 기록" / "님의 한 마디" UI 깨짐. - val senderName = senderMessageInfo?.senderName?.takeIf { it.isNotBlank() }.orEmpty() - val senderMessageBody = senderMessageInfo?.message?.takeIf { it.isNotBlank() } - - _uiState.value = - ReceiverHomeUiState.Success( - senderName = senderName, - // TODO: 백엔드 응답에 date 필드 추가되면 채울 것. 현재 receiver-auth/message 응답은 senderName/message 만. - senderMessage = senderMessageBody?.let { SenderMessage(date = "", body = it) }, - mindRecord = - MindRecordSummary( - totalCount = mindRecordsCount, - dailyQuestionCount = 0, - diaryCount = 0, - deepThoughtCount = 0, - ), - timeLetterTotalCount = timeLettersCount, - afternoteTotalCount = afternotesResult.totalCount, - afternoteIcons = afternotesResult.items.toAfternoteIcons(), - ) } } @@ -134,7 +137,7 @@ class ReceiverHomeViewModel private const val MAX_AFTERNOTE_ICONS = 4 -private fun List.toAfternoteIcons(): List = +private fun List.toAfternoteIcons(): List = asSequence() .mapNotNull { it.sourceType?.takeIf(String::isNotBlank) } .distinct() diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt index cfd6b5ac0..c1e37ba79 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomain.kt @@ -1,21 +1,21 @@ package com.afternote.feature.afternote.data.mapper import com.afternote.feature.afternote.data.dto.ReceivedAfternoteResponse -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItemDto +import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem /** * 서버 카테고리 enum(SOCIAL/GALLERY/PLAYLIST)을 프레젠테이션이 그대로 typeKey로 쓸 수 있는 * 카테고리 키(SOCIAL_NETWORK/GALLERY_AND_FILES/MEMORIAL)로 정규화한다. */ -fun ReceivedAfternoteResponse.toDomain(): AfterNoteListItemDto = - AfterNoteListItemDto( +fun ReceivedAfternoteResponse.toDomain(): AfterNoteListItem = + AfterNoteListItem( id = id, title = title, sourceType = category?.let { serverCategoryToTypeKey(it) }, lastUpdatedAt = createdAt?.let { formatDateFromServer(it) }, ) -fun List.toReceiverDomainList(): List = map { it.toDomain() } +fun List.toReceiverDomainList(): List = map { it.toDomain() } private fun serverCategoryToTypeKey(serverCategory: String): String = when (serverCategory.uppercase()) { diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/paging/ReceiverAfternotePagingSource.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/paging/ReceiverAfternotePagingSource.kt index 8372d3ea3..dc198eb6e 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/paging/ReceiverAfternotePagingSource.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/paging/ReceiverAfternotePagingSource.kt @@ -5,7 +5,7 @@ import androidx.paging.PagingState import com.afternote.core.network.model.requireData import com.afternote.feature.afternote.data.mapper.toReceiverDomainList import com.afternote.feature.afternote.data.service.ReceiverAfternoteApiService -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItemDto +import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem import kotlinx.coroutines.CancellationException /** @@ -17,10 +17,10 @@ import kotlinx.coroutines.CancellationException */ internal class ReceiverAfternotePagingSource( private val api: ReceiverAfternoteApiService, -) : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null +) : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun load(params: LoadParams): LoadResult = + override suspend fun load(params: LoadParams): LoadResult = try { val response = api.getReceiverAfternotes().requireData() LoadResult.Page( diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt index d9f7fcb99..3dedc4ccd 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverRepositoryImpl.kt @@ -8,7 +8,7 @@ import com.afternote.feature.afternote.data.local.ReceiverAuthCodeDataSource import com.afternote.feature.afternote.data.mapper.response.toDomain import com.afternote.feature.afternote.data.paging.ReceiverAfternotePagingSource import com.afternote.feature.afternote.data.service.ReceiverAfternoteApiService -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItemDto +import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult import com.afternote.feature.afternote.domain.model.receiver.LoadCountResult import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail @@ -47,7 +47,7 @@ class ReceiverRepositoryImpl authCodeDataSource.clearCode() } - override fun getPagedReceivedAfternotes(): Flow> = + override fun getPagedReceivedAfternotes(): Flow> = Pager( config = PagingConfig(pageSize = PAGE_SIZE), pagingSourceFactory = { ReceiverAfternotePagingSource(api) }, diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt index 8d951c24b..997ac4d9a 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/mapper/ReceiverAfternoteListItemDtoToDomainTest.kt @@ -7,7 +7,7 @@ import org.junit.Test /** * [ReceivedAfternoteResponse.toDomain] / [toReceiverDomainList] 회귀 가드. - * 수신자 목록 DTO→[com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItemDto] 매핑. + * 수신자 목록 DTO→[com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem] 매핑. * 서버 카테고리(SOCIAL/GALLERY/PLAYLIST/MUSIC)를 presentation typeKey로 정규화하는 규칙과 * null 가드(category·createdAt)를 검증한다. */ diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/receiver/ReceiverAfternoteModels.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/receiver/ReceiverAfternoteModels.kt index 0ab749b84..e5e88395c 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/receiver/ReceiverAfternoteModels.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/model/receiver/ReceiverAfternoteModels.kt @@ -3,11 +3,11 @@ package com.afternote.feature.afternote.domain.model.receiver import com.afternote.feature.afternote.domain.AfternoteServiceType data class AfterNotesListResult( - val items: List, + val items: List, val totalCount: Int, ) -data class AfterNoteListItemDto( +data class AfterNoteListItem( val id: Long, val title: String?, val sourceType: String?, diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt index 0b2036eb8..876aed3f9 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/receiver/ReceiverRepository.kt @@ -1,7 +1,7 @@ package com.afternote.feature.afternote.domain.repository.receiver import androidx.paging.PagingData -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItemDto +import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem import com.afternote.feature.afternote.domain.model.receiver.AfterNotesListResult import com.afternote.feature.afternote.domain.model.receiver.LoadCountResult import com.afternote.feature.afternote.domain.model.receiver.ReceivedAfternoteDetail @@ -32,7 +32,7 @@ interface ReceiverRepository { * 수신 애프터노트 스트림. 서버는 페이지네이션 미지원이므로 단일 페이지로 받지만, * Paging 3 API(LoadState/refresh/cachedIn) 통일과 추후 페이지네이션 도입을 위해 PagingData로 노출한다. */ - fun getPagedReceivedAfternotes(): Flow> + fun getPagedReceivedAfternotes(): Flow> suspend fun getReceivedAfterNotes(): Result diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt index f5dadb943..6c52f751c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt @@ -38,7 +38,11 @@ class AfternoteHostViewModel val isPasskeyRegistered: StateFlow = userProfileRepository .isPasskeyRegisteredFlow() - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null, + ) private val _playlistSongs = MutableStateFlow>(emptyList()) val playlistSongs: StateFlow> = _playlistSongs.asStateFlow() diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeViewModel.kt index bb2ff9787..a94654658 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/home/ReceiverAfternoteHomeViewModel.kt @@ -7,7 +7,7 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import com.afternote.feature.afternote.domain.AfternoteServiceType -import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItemDto +import com.afternote.feature.afternote.domain.model.receiver.AfterNoteListItem import com.afternote.feature.afternote.domain.repository.receiver.ReceiverRepository import com.afternote.feature.afternote.presentation.shared.AfternoteCategory import com.afternote.feature.afternote.presentation.shared.body.infinite.content.list.item.ListItemUiModel @@ -58,7 +58,7 @@ class ReceiverAfternoteHomeViewModel } } -private fun AfterNoteListItemDto.toUiModel(): ListItemUiModel { +private fun AfterNoteListItem.toUiModel(): ListItemUiModel { val typeKey = sourceType.orEmpty() val displayRes = getAfternoteDisplayRes(typeKey) val serviceName = getServiceNameForTypeKey(typeKey)