[feat] #55 #56 사진 즐겨찾기, 사진 API 수정사항 반영#58
Conversation
`AlbumDetailState`가 가지고 있던 `Album` 모델 객체를 `title`과 `photoList`로 분리하여 `Album` 모델에 대한 의존성을 제거합니다.
즐겨찾기 앨범 상세 화면 진입 시 `photoRepository.getFavoritePhotos()`를 호출하여 즐겨찾기된 사진 목록을 불러오도록 구현합니다.
`FavoriteSummary` 모델을 삭제하고 `AlbumPreview` 모델로 대체하여 즐겨찾기 요약 정보를 처리하도록 변경합니다. 이를 통해 즐겨찾기 요약 정보와 일반 앨범 미리보기의 데이터 구조를 일원화합니다. [feat] AlbumPreview 모델 추가 앨범 목록 화면에서 사용할 `AlbumPreview` 데이터 모델을 `core/model` 모듈에 추가합니다.
…오는 로직 구현 아카이브 메인 화면에서 사용하던 `Album` 데이터 모델을 `AlbumPreview`로 변경합니다. 이를 통해 앨범 목록을 표시할 때 전체 사진 목록(`photoList`) 대신 썸네일 URL과 사진 개수(`photoCount`)만 사용하도록 수정하여 데이터 효율성을 개선했습니다.
기존 즐겨찾기 상태를 저장하는 `originalFavorite` 변수를 추가합니다. API 요청 여부를 현재 UI 상태가 아닌 `originalFavorite` 값과 비교하여, 즐겨찾기 상태가 변경되었을 때만 API를 호출하도록 수정합니다. 또한, API 실패 시 UI를 원래 상태로 되돌리고, API 요청 성공 시 `originalFavorite` 값을 업데이트하여 중복 호출을 방지합니다.
아카이브 메인 화면 진입 시 `fetchFavoriteSummary`와 `fetchPhotos` 함수를 `async`와 `awaitAll`을 사용하여 병렬로 호출하도록 변경합니다. 이를 통해 데이터 로딩 시간을 단축하고 사용자 경험을 개선합니다.
보관함 메인 화면의 사진 아이템에 적용되어 있던 수평 20dp 패딩을 제거합니다.
- `ArchiveMainViewModel`에 포함되어 있던 더미 앨범 데이터를 삭제합니다. - `AlbumDetailScreen.kt`와 `ArchiveMainContract.kt`에서 사용되지 않는 `Album` 모델의 import 구문을 제거합니다.
Walkthrough즐겨찾기 API 연동 및 관련 응답/요청 모델 추가, 사진 등록/삭제를 배치(리스트) 기반으로 전환, 업로드 티켓이 복수 반환되도록 API/리포지토리 변경. UI 상태(Album → AlbumPreview) 전환, PhotoDetail에 즐겨찾기 디바운스 저장 흐름과 ApplicationScope DI 추가. Changes
Sequence Diagram(s)sequenceDiagram
participant User as 사용자
participant UI as UI (PhotoDetail)
participant VM as ViewModel (PhotoDetail)
participant Repo as PhotoRepository
participant API as PhotoService
participant Bus as LocalResultEventBus
User->>UI: 즐겨찾기 토글
UI->>VM: handleFavoriteToggle(newStatus)
VM->>VM: optimistic UI 업데이트 (committedFavorite 변경)
VM->>VM: emit to favoriteRequests (MutableSharedFlow)
VM->>VM: debounce 500ms
VM->>Repo: updateFavorite(photoId, newStatus)
Repo->>API: PATCH /api/photos/{photoId}/favorite (UpdateFavoriteRequest)
API-->>Repo: Success / Failure
Repo-->>VM: Result
alt 성공
VM->>VM: emit FavoriteCommitted
VM->>Bus: sendResult(result=true, allowDuplicate=false)
VM->>UI: Notify success (side-effect)
else 실패
VM->>VM: emit RevertFavorite(originalFavorite)
VM->>UI: 롤백 처리
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt (1)
179-193: 업로드 성공 후isLoading이 false로 설정되지 않음
uploadSingleImage에서isLoading = true로 설정한 후, 성공 경로에서는isLoading = false로 복원하지 않습니다.fetchPhotos도isLoading을 변경하지 않으므로, 업로드 성공 시 로딩 상태가 계속 true로 유지됩니다.🐛 수정 제안
uploadSinglePhotoUseCase( imageBytes = imageBytes, ).onSuccess { fetchPhotos(reduce, 1) // 가장 최신 데이터 가져오기 + reduce { copy(isLoading = false) } onSuccess() }.onFailure { error -> Timber.e(error) postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요")) reduce { copy(isLoading = false) } }feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt (1)
237-246: 그리드 패딩 패턴이 다른 화면과 일치하지 않음개별 아이템에
horizontal = 20.dp패딩을 적용하는 방식은 AllPhotoScreen과 AlbumDetailScreen에서 사용하는contentPadding패턴과 다릅니다. 일관성을 위해 다른 아카이브 화면들처럼 그리드의contentPadding에 horizontal 값을 추가하고 개별 아이템의 패딩을 제거하는 것을 권장합니다.패턴 비교
AllPhotoScreen (권장 패턴):
LazyVerticalStaggeredGrid( contentPadding = PaddingValues( start = ARCHIVE_LAYOUT_HORIZONTAL_PADDING.dp, end = ARCHIVE_LAYOUT_HORIZONTAL_PADDING.dp, ... ), ... ) { items(...) { photo -> SelectablePhotoItem(photo = photo) // 패딩 없음 } }ArchiveMainScreen (현재):
LazyVerticalStaggeredGrid( contentPadding = PaddingValues(bottom = ...), // horizontal 없음 ... ) { items(...) { photo -> ArchiveMainPhotoItem( modifier = Modifier.padding(horizontal = 20.dp) // 개별 패딩 ) } }
🤖 Fix all issues with AI agents
In
`@core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoritePhotoResponse.kt`:
- Around line 8-29: FavoritePhotoResponse.Item.toModel() currently omits mapping
the favorite flag, causing Photo instances to have the default isFavorite=false;
update FavoritePhotoResponse.Item.toModel() to set isFavorite = favorite when
constructing Photo (refer to FavoritePhotoResponse.Item.toModel and the Photo
constructor), and while here evaluate whether to include folderId and
contentType in the Photo mapping or drop them intentionally; also confirm
whether discarding hasNext in FavoritePhotoResponse.toModels() is intended or if
the caller should receive pagination info instead.
In
`@core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt`:
- Around line 12-17: FavoriteSummaryResponse.toModel currently hardcodes the
title "즐겨찾는사진" in the data layer; remove the hardcoded Korean string and instead
supply a title via a constant or resource key passed from upper layers
(UI/domain). Update the toModel implementation in FavoriteSummaryResponse to use
a provided value (e.g., a constant like AlbumPreview.DEFAULT_TITLE or a title
parameter) or return a title placeholder/enum that the UI will map to a
localized string resource, ensuring no UI strings are embedded in the data
layer.
In
`@core/data/src/main/java/com/neki/android/core/data/remote/model/response/PhotoResponse.kt`:
- Around line 15-26: PhotoResponse declares `@SerialName`("favorite") val
isFavorite: Boolean as non-nullable without a default, causing
MissingFieldException for responses missing that field; add a default value
(e.g., = false) to the isFavorite property in the PhotoResponse data class so
kotlinx.serialization treats it as optional and the existing toModel() mapping
(toModel -> Photo(... isFavorite = isFavorite ...)) continues to work with the
domain Photo default.
In
`@core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt`:
- Around line 13-28: Validate uploadCount at the start of getUploadTicket and
fail fast if it's <= 0: inside the suspend function getUploadTicket (before
constructing MediaUploadTicketRequest and the List(uploadCount) call), check
uploadCount and throw an IllegalArgumentException (or return a failed result)
with a clear message like "uploadCount must be greater than 0" so the request is
never built with zero/negative items.
In
`@core/data/src/main/java/com/neki/android/core/data/repository/impl/PhotoRepositoryImpl.kt`:
- Around line 28-44: The methods registerPhoto and deletePhoto in
PhotoRepositoryImpl should short-circuit when mediaIds or photoIds are empty to
avoid unnecessary network calls; change the expression-bodied overrides to block
bodies, check if mediaIds.isEmpty() / photoIds.isEmpty() and immediately return
Result.success(Unit) before invoking runSuspendCatching/photoService, ensuring
you update registerPhoto(mediaIds: List<Long>) and deletePhoto(photoIds:
List<Long>) accordingly.
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt`:
- Around line 59-62: When handling AlbumDetailIntent.EnterAlbumDetailScreen (and
the similar branch around lines 94-105), failures from fetchFavoriteData and
fetchAlbumData currently only log errors and leave the UI empty; update those
functions' failure paths to surface an error state or show a user-facing message
(e.g., emit a UiState error, set a failure flag, or trigger a Toast/Event) so
the View can display an error/empty-state UI or toast. Specifically, modify the
error callbacks in fetchFavoriteData and fetchAlbumData (and any related
result-handling in AlbumDetailViewModel) to dispatch a distinct error
event/state that the view observes and renders to inform the user.
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt`:
- Around line 137-145: The fetchPhotos function defines a size parameter but
never uses it; update the implementation in ArchiveMainViewModel to either
remove the unused size parameter from fetchPhotos or pass it into the repository
call (use photoRepository.getPhotos(size) if the API supports pagination/limit),
and ensure the reduce/update logic (recentPhotos assignment) remains unchanged;
search for fetchPhotos and photoRepository.getPhotos to apply the fix
consistently.
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt`:
- Around line 27-49: The current logic updates originalFavorite regardless of
API result, causing future toggles to be skipped when updateFavorite fails;
change the flow so originalFavorite is updated only on successful update: inside
the favoriteRequests.collectLatest block, call
photoRepository.updateFavorite(photo.id, newFavorite) and in its onSuccess
handler set originalFavorite = newFavorite (and keep the Timber.d log), while in
onFailure keep Timber.e and
store.onIntent(PhotoDetailIntent.RevertFavorite(originalFavorite)) so failed
updates do not mutate originalFavorite; adjust references to favoriteRequests,
originalFavorite, photoRepository.updateFavorite, and
PhotoDetailIntent.RevertFavorite accordingly.
🧹 Nitpick comments (3)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt (1)
10-20: 다이얼로그 플래그 중복 상태 방지 고려
isShowAddDialog,isShowChooseWithAlbumDialog,isShowAddAlbumBottomSheet가 동시에 true가 될 수 있어 상태 불일치 위험이 있습니다. 하나의 enum/ sealed 상태로 통합하면 UI 일관성 유지에 도움이 됩니다.core/data/src/main/java/com/neki/android/core/data/remote/model/request/RegisterPhotoRequest.kt (1)
7-15: uploads 비어있는 케이스에 대한 방어 로직 권장현재
uploads가 빈 리스트여도 생성 가능해 API 400이나 무의미한 호출로 이어질 수 있습니다. 생성 시점에 최소 1개 이상을 보장하도록 방어 로직을 두는 걸 권장합니다.♻️ 제안 변경
`@Serializable` data class RegisterPhotoRequest( `@SerialName`("folderId") val folderId: Long? = null, `@SerialName`("uploads") val uploads: List<Upload>, ) { + init { + require(uploads.isNotEmpty()) { "uploads must not be empty" } + } + `@Serializable` data class Upload( `@SerialName`("mediaId") val mediaId: Long, `@SerialName`("memo") val memo: String? = null,core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt (1)
7-12: uploadCount 계약(>=1) 명시 권장
uploadCount가 0 이하로 들어오면 요청 모델이 비게 됩니다. KDoc로 범위를 명시하거나 구현체에서require(uploadCount >= 1)로 방어하는 것을 권장합니다.
| fun toModel() = AlbumPreview( | ||
| id = -1L, | ||
| title = "즐겨찾는사진", | ||
| thumbnailUrl = latestImageUrl, | ||
| photoCount = totalCount, | ||
| ) |
There was a problem hiding this comment.
문자열 하드코딩으로 i18n 이슈 가능
"즐겨찾는사진"이 데이터 레이어에 하드코딩되어 있어 다국어/브랜딩 변경 시 확장성이 떨어집니다. UI 레이어의 리소스 또는 공통 상수로 이동을 권장합니다.
🤖 Prompt for AI Agents
In
`@core/data/src/main/java/com/neki/android/core/data/remote/model/response/FavoriteSummaryResponse.kt`
around lines 12 - 17, FavoriteSummaryResponse.toModel currently hardcodes the
title "즐겨찾는사진" in the data layer; remove the hardcoded Korean string and instead
supply a title via a constant or resource key passed from upper layers
(UI/domain). Update the toModel implementation in FavoriteSummaryResponse to use
a provided value (e.g., a constant like AlbumPreview.DEFAULT_TITLE or a title
parameter) or return a title placeholder/enum that the UI will map to a
localized string resource, ensuring no UI strings are embedded in the data
layer.
`ArchiveMainViewModel.kt`에서 사용되지 않는 `AlbumPreview` import 문을 삭제합니다.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt`:
- Around line 115-123: fetchInitialData currently sets isLoading=true, calls
awaitAll on async children, and then sets isLoading=false, but if awaitAll
throws the finally block never runs; wrap the concurrent work in a try/finally
so reduce { copy(isLoading = false) } always executes (e.g., in fetchInitialData
use try { awaitAll(...) } finally { reduce { copy(isLoading = false) } }).
Optionally use supervisorScope around async { fetchFavoriteSummary(reduce) } and
async { fetchPhotos(reduce) } so one child's failure doesn't cancel the others,
but ensure the finally still clears loading.
♻️ Duplicate comments (1)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt (1)
136-144:size파라미터가 호출에 반영되지 않음
fetchPhotos(reduce, size = 1)같은 호출이 있어도photoRepository.getPhotos()에 전달되지 않아 제한이 무시됩니다. size를 전달하거나 파라미터를 제거해 주세요.🔧 수정 제안
- private suspend fun fetchPhotos(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, size: Int = DEFAULT_PHOTOS_SIZE) { - photoRepository.getPhotos() + private suspend fun fetchPhotos(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, size: Int = DEFAULT_PHOTOS_SIZE) { + photoRepository.getPhotos(size = size) .onSuccess { data -> reduce { copy(recentPhotos = data.toImmutableList()) } } .onFailure { error -> Timber.e(error) } }또는 size 지원이 없다면 파라미터를 제거해 주세요.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/component/NoPhotoContent.kt`:
- Around line 40-44: Replace the hard-coded Korean message in the Text
composable inside NoPhotoContent (the Text call in NoPhotoContent.kt) with a
stringResource lookup: add an entry archive_no_photo_message to strings.xml and
use stringResource(R.string.archive_no_photo_message) for the text parameter;
keep the existing style (NekiTheme.typography.body14Medium), color
(NekiTheme.colorScheme.gray300) and textAlign (TextAlign.Center) unchanged so
only the source of the string is moved to resources.
사진의 즐겨찾기 상태 업데이트에 실패했을 때, 이전 상태로 복구하는 로직에서 발생하던 버그를 수정합니다. `onSuccess` 콜백 내에서 `originalFavorite` 상태를 업데이트하도록 하여, 즐겨찾기 업데이트 요청이 실패한 후 다시 성공했을 때 `newFavorite`와 `originalFavorite`가 동일해져 업데이트가 누락되는 현상을 해결했습니다.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt`:
- Around line 27-52: The current guard using originalFavorite in the
favoriteRequests.collectLatest block can let an earlier-cancelled network
request still complete on the server and cause client/server state drift; remove
the if (originalFavorite != newFavorite) check so the block always calls
photoRepository.updateFavorite(photo.id, newFavorite) (keep the debounce(500) +
collectLatest behavior), retain the onSuccess handler to set originalFavorite =
newFavorite and the onFailure handler to log and call
store.onIntent(PhotoDetailIntent.RevertFavorite(originalFavorite)); this ensures
every debounced intent is sent to the server and client state is reconciled via
the existing success/failure handlers (referencing favoriteRequests,
originalFavorite, photoRepository.updateFavorite, collectLatest, debounce, and
store.onIntent(PhotoDetailIntent.RevertFavorite(...))).
- MviIntentStore에 postSideEffect 메서드 public 노출 - PhotoDetail에서 즐겨찾기/삭제 시 NotifyArchiveUpdated SideEffect 방출 - ResultEventBus를 통해 ArchiveMain에 변경 알림 전달 - fetchJob.isActive 체크로 중복 API 호출 방지
`ArchiveMainState`의 `favoriteAlbum` 초기값을 설정할 때, "즐겨찾는사진"이라는 기본 제목을 부여하도록 수정합니다.
`SupervisorJob`과 `Dispatchers.Default`를 사용하는 `ApplicationScope`를 Hilt 모듈로 추가합니다. [refactor] 사진 상세화면의 즐겨찾기 로직을 ApplicationScope에서 처리 사진 상세 화면(`PhotoDetailViewModel`)에서 즐겨찾기 상태를 업데이트하는 로직을 기존 `viewModelScope`에서 `ApplicationScope`로 이전합니다. 이를 통해 화면이 종료되어도 즐겨찾기 요청이 취소되지 않고 계속 처리되도록 수정합니다.
`PhotoDetailViewModel`에서 즐겨찾기 상태를 업데이트하는 코루틴의 실행 스코프를 `viewModelScope`에서 `applicationScope`로 변경합니다. 이를 통해 화면이 종료된 후에도 즐겨찾기 업데이트 요청이 취소되지 않고 끝까지 실행되도록 보장합니다.
`MviIntentStore` 인터페이스에서 `postSideEffect` 함수를 제거하고, `MviIntentStoreImpl`에서는 해당 함수의 접근 제어자를 `private`으로 변경합니다. 이를 통해 ViewModel에서는 `onIntent`를 통해서만 SideEffect를 발생시키도록 강제하여, 단방향 데이터 흐름을 강화합니다.
미디어 업로드 티켓 발급 API의 응답 형식이 단일 객체에서 리스트 형태로 변경됨에 따라 관련 데이터 모델을 수정합니다. - `MediaUploadTicketResponse`를 `MediaUploadTicketDataResponse`로 리네임하고, 내부에 `MediaUploadTicketItemResponse`를 포함하도록 구조를 변경합니다. - `toModel()` 확장 함수를 `toModels()`로 변경하여 `List<MediaUploadTicket>`을 반환하도록 수정합니다. - 이에 따라 `MediaUploadRepository`의 `getUploadTicket` 메서드의 반환 타입을 `Result<MediaUploadTicket>`에서 `Result<List<MediaUploadTicket>>`으로 변경합니다. - 단일 사진을 업로드하는 `UploadSinglePhotoUseCase`에서는 티켓 리스트의 첫 번째 아이템을 사용하도록 수정합니다.
`ResultEventBus.sendResult`에 `allowDuplicate` 파라미터를 추가하여, `false`로 설정 시 중복된 결과값이 발행되는 것을 방지하도록 `Channel.CONFLATED`를 사용합니다. [refactor] 사진 상세화면에서 중복 결과 전송 방지 사진 상세화면에서 보관함 업데이트 알림을 보낼 때 `allowDuplicate = false` 옵션을 사용하여, 보관함 화면으로 돌아갈 때 이벤트가 중복으로 전송되는 것을 막습니다.
`LazyVerticalStaggeredGrid`에 `contentPadding`을 직접 적용하여, 각 `item`에 개별적으로 적용되던 수평 `padding`을 제거합니다. 이를 통해 코드의 중복을 줄이고 레이아웃 관리 효율을 높였습니다.
이미지 추가 성공 시 `isLoading` 상태를 `false`로 변경하여 로딩 표시를 중단하도록 수정합니다.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt`:
- Around line 38-57: The coroutine launched in applicationScope calls
store.onIntent(...) after async work which is unsafe if the ViewModel is
destroyed; change the flow so intents are emitted from the ViewModel's lifecycle
scope: after photoRepository.updateFavorite(...) completes, dispatch
store.onIntent(...) from viewModelScope (or re-launch into viewModelScope)
instead of calling it directly from applicationScope, or cancel the
debounce/collect when ViewModel is cleared; locate the init block with
applicationScope.launch, the use of photoRepository.updateFavorite,
originalFavorite, and the two intents PhotoDetailIntent.FavoriteCommitted and
PhotoDetailIntent.RevertFavorite and ensure intent dispatch uses viewModelScope
(or a safe isActive check) to avoid launching into a cancelled scope.
🧹 Nitpick comments (5)
core/data-api/src/main/java/com/neki/android/core/dataapi/repository/MediaUploadRepository.kt (1)
7-12:uploadCount파라미터 기본값 불일치 수정 필요인터페이스에서
uploadCount: Int = 1로 기본값이 정의되어 있지만, 구현체에서는 기본값이 없습니다. 기본값을 일관되게 맞춰주세요.다중 파일 업로드 설계는 현재 단일 업로드(
uploadCount=1)만 사용되고 있어 문제가 없지만, 향후 다중 업로드 지원 시에는 각 파일별 메타데이터를 받을 수 있도록 인터페이스 개선을 고려할 수 있습니다:향후 개선 시 고려 가능한 대안
data class UploadFileInfo( val fileName: String, val contentType: String, val mediaType: String, ) suspend fun getUploadTickets( files: List<UploadFileInfo>, ): Result<List<MediaUploadTicket>>core/navigation/src/main/java/com/neki/android/core/navigation/result/ResultEventBus.kt (1)
47-54: 채널이 이미 존재할 경우allowDuplicate파라미터가 무시됩니다.채널 타입은 최초 생성 시에만 결정됩니다. 동일한
resultKey에 대해 다른allowDuplicate값으로sendResult를 호출하면, 기존 채널이 그대로 사용되어 예상과 다른 동작이 발생할 수 있습니다.♻️ 제안된 수정
inline fun <reified T> sendResult( resultKey: String = T::class.toString(), result: T, allowDuplicate: Boolean = true, ) { - if (!channelMap.contains(resultKey)) { - channelMap[resultKey] = if (allowDuplicate) { - Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) - } else { - Channel(capacity = Channel.CONFLATED) - } + val existingChannel = channelMap[resultKey] + val needsNewChannel = existingChannel == null + + if (needsNewChannel) { + val newChannel = if (allowDuplicate) { + Channel<Any?>(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } else { + Channel<Any?>(capacity = Channel.CONFLATED) + } + channelMap[resultKey] = newChannel } channelMap[resultKey]?.trySend(result) }또는, 다른
allowDuplicate요구사항에 대해 별도의resultKey를 사용하는 것을 고려해 주세요.feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailScreen.kt (1)
42-42: Result key로Boolean타입을 사용하면 다른 Boolean 결과와 충돌할 수 있습니다.기본
resultKey가Boolean::class.toString()으로 설정되어, 앱 내 다른 Boolean 타입 결과와 충돌할 가능성이 있습니다. 명시적인 키 사용을 권장합니다.♻️ 제안된 수정
- PhotoDetailSideEffect.NotifyArchiveUpdated -> resultEventBus.sendResult(result = true, allowDuplicate = false) + PhotoDetailSideEffect.NotifyArchiveUpdated -> resultEventBus.sendResult( + resultKey = "archive_updated", + result = true, + allowDuplicate = false + )수신 측에서도 동일한 키를 사용해야 합니다.
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt (2)
29-29:extraBufferCapacity = 64가 과도합니다.단순 Boolean 토글에 64개의 버퍼는 불필요합니다.
debounce(500)와 함께 사용하면 1-4 정도로 충분합니다.♻️ 제안된 수정
- private val favoriteRequests = MutableSharedFlow<Boolean>(extraBufferCapacity = 64) + private val favoriteRequests = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
93-94:viewModelScope.launch로emit을 래핑할 필요가 없습니다.
handleFavoriteToggle은 이미 메인 스레드에서 호출되고,MutableSharedFlow.emit은 버퍼에 여유가 있으면 즉시 완료됩니다.tryEmit을 사용하면 코루틴 없이 동기적으로 처리할 수 있습니다.♻️ 제안된 수정
private fun handleFavoriteToggle( state: PhotoDetailState, reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, postSideEffect: (PhotoDetailSideEffect) -> Unit, ) { val newFavoriteStatus = !state.photo.isFavorite - viewModelScope.launch { favoriteRequests.emit(newFavoriteStatus) } + favoriteRequests.tryEmit(newFavoriteStatus) reduce { copy(photo = state.photo.copy(isFavorite = newFavoriteStatus)) } }
`PhotoDetailState` 내에서 즐겨찾기 요청 `MutableSharedFlow`를 관리하도록 변경합니다. 이를 통해 기존 ViewModel 프로퍼티로 관리하던 즐겨찾기 상태(`originalFavorite`)를 `PhotoDetailState`의 `committedFavorite`로 이전하여 상태 관리의 일관성을 확보했습니다. - `favoriteRequests`를 `PhotoDetailState`로 이동 - `FavoriteCommitted` 인텐트가 새로운 즐겨찾기 상태(`newFavorite`)를 포함하도록 수정 - 즐겨찾기 상태 업데이트 및 롤백 로직을 `committedFavorite` 기준으로 변경
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (1)
221-242: REMOVE_FROM_ALL 옵션에서 서버 삭제 API 호출 누락
PhotoDeleteOption.REMOVE_FROM_ALL선택 시 사진이 서버에서 완전히 삭제되어야 하지만, 현재는 로컬 상태만 업데이트합니다.handleFavoriteDelete처럼photoRepository.deletePhoto()를 호출해야 할 것 같습니다.🔧 제안 수정안
private fun handleAlbumDelete( state: AlbumDetailState, reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit, postSideEffect: (AlbumDetailSideEffect) -> Unit, ) { + when (state.selectedDeleteOption) { + PhotoDeleteOption.REMOVE_FROM_ALBUM -> { + // TODO: Call API to remove photo-album association + reduce { + copy( + photoList = photoList.filter { photo -> + selectedPhotos.none { it.id == photo.id } + }.toImmutableList(), + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteBottomSheet = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("앨범에서 사진을 제거했어요")) + } + PhotoDeleteOption.REMOVE_FROM_ALL -> { + viewModelScope.launch { + reduce { copy(isLoading = true) } + val selectedPhotoIds = state.selectedPhotos.map { it.id } + photoRepository.deletePhoto(photoIds = selectedPhotoIds) + .onSuccess { + reduce { + copy( + photoList = photoList.filter { photo -> + selectedPhotos.none { it.id == photo.id } + }.toImmutableList(), + selectedPhotos = persistentListOf(), + selectMode = SelectMode.DEFAULT, + isShowDeleteBottomSheet = false, + ) + } + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요")) + } + .onFailure { error -> + Timber.e(error) + postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요")) + } + reduce { copy(isLoading = false) } + } + } + } - reduce { - copy( - photoList = photoList.filter { photo -> - selectedPhotos.none { it.id == photo.id } - }.toImmutableList(), - selectedPhotos = persistentListOf(), - selectMode = SelectMode.DEFAULT, - isShowDeleteBottomSheet = false, - ) - } - - val message = when (state.selectedDeleteOption) { - PhotoDeleteOption.REMOVE_FROM_ALBUM -> "앨범에서 사진을 제거했어요" - PhotoDeleteOption.REMOVE_FROM_ALL -> "사진을 삭제했어요" - } - postSideEffect(AlbumDetailSideEffect.ShowToastMessage(message)) }feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt (1)
145-153: 로딩 중 EmptyContent가 함께 표시되는 문제
isLoading이true이고photoList가 비어있을 때 (초기 로딩 시)LoadingDialog와EmptyContent가 동시에 표시됩니다. 로딩이 완료된 후에만 빈 상태를 보여줘야 합니다.🔧 제안 수정안
if (uiState.isLoading) { LoadingDialog() } - if (uiState.photoList.isEmpty()) { + if (!uiState.isLoading && uiState.photoList.isEmpty()) { EmptyContent( isFavorite = uiState.isFavoriteAlbum, ) }feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt (1)
88-98:favoriteRequests를 ViewModel 프로퍼티로 이동하면 이 코드도 함께 수정 필요합니다.현재
state.favoriteRequests로 emit하지만, Contract의 이슈 수정 후에는 ViewModel의favoriteRequests프로퍼티로 emit해야 합니다.🔧 수정 제안
private fun handleFavoriteToggle( state: PhotoDetailState, reduce: (PhotoDetailState.() -> PhotoDetailState) -> Unit, postSideEffect: (PhotoDetailSideEffect) -> Unit, ) { val newFavoriteStatus = !state.photo.isFavorite - viewModelScope.launch { state.favoriteRequests.emit(newFavoriteStatus) } + viewModelScope.launch { favoriteRequests.emit(newFavoriteStatus) } reduce { copy(photo = state.photo.copy(isFavorite = newFavoriteStatus)) } }
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailContract.kt`:
- Around line 6-13: PhotoDetailState declares favoriteRequests (a
MutableSharedFlow) inside the data class body which causes a new instance on
every copy() (e.g., during reduce { copy(...) }) breaking subscriptions; move
favoriteRequests out of PhotoDetailState and into the PhotoDetailViewModel as a
private property (e.g., private val favoriteRequests =
MutableSharedFlow<Boolean>(extraBufferCapacity = 64)) and update usages so the
ViewModel exposes or consumes that flow instead of the state object, ensuring
PhotoDetailState remains immutable and copy() no longer recreates the flow.
🧹 Nitpick comments (2)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (1)
200-202: 디버그 로그 정리 필요
"삭제 성공2"디버그 로그가 남아있습니다. 프로덕션 빌드 전에 제거하거나 의미 있는 메시지로 변경해주세요.🔧 제안 수정안
.onSuccess { - Timber.d("삭제 성공2") + Timber.d("Favorite photos deleted successfully: $selectedPhotoIds") fetchFavoriteData(reduce)feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt (1)
145-147: 로딩 다이얼로그 실수로 닫힘 방지 권장현재
LoadingDialog는 기본DialogProperties를 사용하여 백 버튼이나 외부 클릭으로 닫을 수 있습니다. 로딩 중 실수로 닫히는 것을 방지하려면dismissOnBackPress와dismissOnClickOutside를false로 설정하는 것이 좋습니다.🔧 제안 수정안
+import androidx.compose.ui.window.DialogProperties ... if (uiState.isLoading) { - LoadingDialog() + LoadingDialog( + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) }
`PhotoDetailState`에 있던 `favoriteRequests` `MutableSharedFlow`를 `PhotoDetailViewModel`로 이동시킵니다. State는 UI 상태를 나타내는 순수한 데이터 클래스로 유지하고, 이벤트 처리는 ViewModel에서 담당하도록 역할을 분리합니다.
`PhotoDetailViewModel` 내 `favoriteRequests`의 가시성을 `public`에서 `private`으로 변경하여 캡슐화를 강화합니다. 또한, `handleFavoriteToggle` 함수에서 더 이상 사용되지 않는 `postSideEffect` 파라미터를 제거하여 코드를 간결하게 수정합니다.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt`:
- Around line 31-33: The initial committedFavorite currently defaults to false
which can mismatch the PhotoDetailState(photo = photo) and skip the first
toggle; update the initial committedFavorite to match photo.isFavorite when
calling mviIntentStore (or where PhotoDetailState is created) so the stored
committedFavorite and the state's photo.isFavorite are aligned; locate
references to committedFavorite, PhotoDetailState(photo = photo), and
mviIntentStore/onIntent to set the initial committedFavorite to photo.isFavorite
(also apply the same fix at the other occurrence mentioned around lines 41-43).
포토카드 상세화면 `PhotoDetailState`의 `committedFavorite` 초기값을 `photo.isFavorite` 값으로 설정하여, 화면 진입 시 서버의 좋아요 상태를 정확히 반영하도록 수정합니다.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt`:
- Around line 237-239: The preview for the empty album sets title = "빈 앨범" but
mistakenly uses isFavoriteAlbum = true; update the preview data used by
AlbumDetailScreen (the empty-album preview instance in AlbumDetailScreen.kt) to
set isFavoriteAlbum = false so the empty-album UI renders as a non-favorite
album; locate the preview/fixture where title = "빈 앨범" and change the
isFavoriteAlbum flag on that preview object instead of leaving it true.
- Around line 146-151: The EmptyContent is shown while uiState.isLoading which
causes duplicate/flicker UI; change the condition that renders EmptyContent to
only run when loading has finished and the list is empty by checking
!uiState.isLoading && uiState.photoList.isEmpty(), keeping LoadingDialog()
rendering when uiState.isLoading; update the render logic around
LoadingDialog(), EmptyContent and any related branches in AlbumDetailScreen so
EmptyContent is only displayed after loading completes.
Ojongseok
left a comment
There was a problem hiding this comment.
고생하셨습니다!
Q. 프로젝트 진행하며 즐겨찾기 버튼에 처음 디바운싱이 적용된 것 같고 적절한 케이스인 것 같습니다.
[즐겨찾기 사진 삭제]의 경우 아주 짧은 시간에 중복 삭제 요청을 하더라도 서버에서 이미 삭제된 사진이기 때문에 문제가 안될 것 같은데 ex. [사진 등록]의 경우 중복 등록 요청 시에 같은 이미지가 여러번 등록될 수 있을 것 같은데 이런 경우도 디바운싱을 적용할 필요가 있을까요?
디바운싱을 적용할 컴포넌트나 화면은 본인의 판단으로 적용을 하면 될까요?
Q. 값이 변경될 시 업데이트하는 Result를 방출하는 것 괜찮은지?
사실 hasUpdated 와 initialFetch가 중복�호출되어도 UX 상에는 문제가 없죠.? 불필요한 API 호출을 줄이시려는 의도라면 아주 좋은 것 같습니다!
| init { | ||
| applicationScope.launch { | ||
| favoriteRequests | ||
| .debounce(500) | ||
| .collect { newFavorite -> | ||
| val committedFavorite = store.uiState.value.committedFavorite | ||
| if (committedFavorite != newFavorite) { | ||
| photoRepository.updateFavorite(photo.id, newFavorite) | ||
| .onSuccess { | ||
| Timber.d("updateFavorite success") | ||
| store.onIntent(PhotoDetailIntent.FavoriteCommitted(newFavorite)) | ||
| } | ||
| .onFailure { error -> | ||
| Timber.e(error, "updateFavorite failed") | ||
| store.onIntent(PhotoDetailIntent.RevertFavorite(committedFavorite)) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
500ms 내의 사용자가 화면을 나가서 viewModelScope 가 취소되는 것을 방지하기 위해서 전역 코루틴을 구현했습니다.
의 의도는 완전 이해했습니다.
다만, ViewModel에 init {}된 이후 포즈 상세 화면을 이탈하더라도 ApplicationScope에 등록 됐으니 favoriteRequests에 대한 collect {} 블럭은 계속 구독중인 것 아닌가요?
|
로딩 상태(`isLoading`)가 아닐 때에만 사진 목록이 비어있는지 확인하도록 조건을 추가하여, 앨범 상세 데이터를 불러오는 중에 "앨범이 비어있어요" 안내 문구가 잠시 나타나는 현상을 수정합니다.
ViewModel이 `onCleared` 될 때, 사진의 현재 좋아요 상태(`isFavorite`)와 초기 좋아요 상태(`committedFavorite`)를 비교합니다. 두 상태가 다를 경우, `photoRepository.updateFavorite`를 호출하여 변경된 좋아요 상태를 서버에 반영합니다. 이를 통해 사용자가 화면을 벗어났을 때 좋아요 상태가 최종적으로 업데이트되도록 보장합니다.
- `PhotoDetailContract.kt`에서 사용하지 않는 `MutableSharedFlow` import를 제거합니다. - `AlbumDetailScreen.kt`에서 `DeleteOptionBottomSheet` import를 제거합니다.
Ojongseok
left a comment
There was a problem hiding this comment.
확인했습니다.
favoriteRequests을 구독하는 부분은 init {} 블럭 내에 그대로 두어도 괜찮을 것 같고 mviIntentStore의 initialFetchData 를 통해 별도 함수 내에서 구독을 시작해도 괜찮을 것 같습니다.
편하신 방향으로 진행해주세요!
🔗 관련 이슈
📙 작업 설명
📸 스크린샷 또는 시연 영상 (선택)
default.mp4
default.mp4
💬 추가 설명 or 리뷰 포인트 (선택)
즐겨찾기/사진 삭제 후 화면 갱신
debounce(500ms)를 적용하여 빠른 연속 클릭 시 과도한 API 호출을 방지했습니다.MutableSharedFlow변수를 추가하여ViewModel의init블록에서SharedFlow를 구독하도록 구현했습니다.ResultEventBus에 Boolean Result 를 전달하고, 아카이빙 메인에서 전달받도록 구현했습니다.allowDuplicate = false일 때, Result Channel 에capacity = Channel.CONFLATED를 추가했습니다.Q. 값이 변경될 시 업데이트하는 Result를 방출하는 것 괜찮은지?
현재 uiState에서 default 로 5초 안에 새로운 구독이 생기면
onStart { }블록의 함수가 실행되지 않는데, (ex)fetchInitialData())사용자가 아카이빙 메인 -> 사진 상세 -> 사진 삭제 -> 아카이빙 메인
이 흐름이 5초 안에 이루어지면은 홈 화면에 자동적으로 API 호출 및 UI 갱신이 되지 않습니다.
그래서 Result 를 방출하여 hasUpdated == true 라면 Refresh Intent 를 호출하도록 구현했습니다.
또한 Result를 수신받는 쪽에서 fetchJob 객체를 사용하여 hasUpdated 와 initialFetch 함수가 중복으로 호출되지 않게끔 구현했습니다.
Summary by CodeRabbit
새로운 기능
개선사항
기타
✏️ Tip: You can customize this high-level summary in your review settings.