Skip to content

[feat] #59, #64, #69 아카이브 API 연동 및 페이징 구현#73

Merged
Ojongseok merged 40 commits into
developfrom
feat/#59,64,69-archive-api
Feb 2, 2026
Merged

[feat] #59, #64, #69 아카이브 API 연동 및 페이징 구현#73
Ojongseok merged 40 commits into
developfrom
feat/#59,64,69-archive-api

Conversation

@ikseong00
Copy link
Copy Markdown
Contributor

@ikseong00 ikseong00 commented Jan 29, 2026

🔗 관련 이슈

📙 작업 설명

페이징 (Paging3)

  • 앨범 상세 화면 사진 목록 페이징 적용
  • 전체 사진 화면 페이징 적용
  • 즐겨찾기 사진 페이징 적용
  • PagingSource 구현 (PhotoPagingSource, FavoritePhotoPagingSource)
  • 삭제된 사진 로컬 필터링 (API 재호출 없이 deletedPhotoIds로 필터링)

사진 업로드 (#64)

  • 여러 장 사진 업로드 기능 구현
  • 미디어 업로드 티켓 발급 API 분리
  • 병렬 업로드 및 Dispatcher 적용

앨범/폴더 API (#69)

  • 폴더 생성 및 목록 조회 API 구현
  • 앨범 추가/삭제 기능 구현
  • 전체 앨범 화면 API 연동
  • 앨범 상세화면 사진 목록 API 연동
  • 폴더 삭제 시 deletePhotos 쿼리 파라미터 추가 (사진 포함/미포함 삭제 분기)
  • 앨범 상세에서 사진 삭제 시 옵션별 분기 (앨범에서만 제거 / 모든 위치에서 삭제)

필터링 기능

  • 전체 사진 화면 최신순/오래된순 필터링
  • 즐겨찾기 필터 토글 기능
  • 필터 변경 시 스크롤 위치 초기화 (LoadState 기반)

📸 스크린샷 또는 시연 영상 (선택)

기능 미리보기 기능 미리보기
아카이빙 앨범추가
default.mp4
여러 개 사진 바로 업로드
default.mp4
여러 개 사진 앨범 선택 후 업로드
default.mp4
일반 앨범 조회
default.mp4
앨범에서 사진 삭제
default.mp4
즐겨찾기 사진 삭제
default.mp4
즐겨찾는 사진 조회
default.mp4
모든앨범 앨범추가
default.mp4
모든 앨범 앨범 삭제
default.mp4
모든사진 사진삭제
default.mp4

💬 추가 설명 or 리뷰 포인트 (선택)

폴더 삭제 및 사진 삭제 옵션 분기

  • DELETE /api/foldersdeletePhotos 쿼리 파라미터 추가
    • AlbumDeleteOption.DELETE_WITH_PHOTOSdeletePhotos=true (사진까지 함께 삭제)
    • AlbumDeleteOption.DELETE_ALBUM_ONLYdeletePhotos=false (앨범만 삭제)
  • DELETE /api/folders/{folderId}/photos API 추가
    • PhotoDeleteOption.REMOVE_FROM_ALBUM → 폴더에서 사진 연관관계만 해제
    • PhotoDeleteOption.REMOVE_FROM_ALLDELETE /api/photos로 사진 완전 삭제

모든 사진에서 ScrollToTop 이슈

  • 모든 사진에서, PagingData 를 사용하는데, 필터 값에 따라 사용하는 Flow 가 달라집니다.
  • 이 때 서버로부터 Flow 스트림을 받는데, 기존 코드로는 Flow 스트림이 업데이트 되기 전에 ScrollToTop 이 적용됩니다. 그 이후 새로운 스트림을 받기 때문에 최신순/오래된순 변경 시에 관측하던 아이템이 최상단일 것이고, 정렬이 바뀌며 스크롤이 최하단으로 갑니다.
    정렬 변경 -> ScrollToTop -> 최상단으로 이동 -> 새로운 Flow 수신 -> 최하단으로 스크롤 이동됨.
  • 그래서 이벤트 수신 시, Flow 값이 refresh 이면서, 2번째 NotLoading 일 경우 스크롤탑 이벤트가 정상 동작하도록 했습니다. (기본값이 NotLoading이라 첫번째 NotLoading은 버림)

ArchiveResult

  • 즐겨찾기, 사진삭제 결과를 포토 상세에서 뿌리도록 구현했습니다.
  • 모든 사진, 앨범상세에서 해당 결과를 관측합니다.

PagingData 한계

  • Paging 데이터가 Immutable 이라 삭제나 좋아요 변화 등을 커스텀하기 어렵습니다.
  • 추후에 Paging을 수동으로 구현하여 데이터를 관리하기 쉽게 수정하려 합니다.

Q. 네이밍

  • 현재 API 스펙에 folder로 되어있는데 저희도 맞출까요?

고봉밥 죄송합니다... 여행 가서도 짬내서 코멘트 확인해보겠습니다!

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 폴더 생성 및 삭제 기능 추가
    • 여러 사진을 한 번에 업로드할 수 있는 기능 추가
    • 사진 페이지네이션 지원 (일반 및 즐겨찾기 모두)
    • 폴더에서 사진 제거 기능 추가
    • URL에서 이미지 업로드 지원
  • 개선

    • 앨범 데이터 모델 개선으로 더 효율적인 로딩
    • 정렬 순서 옵션 추가

✏️ Tip: You can customize this high-level summary in your review settings.

Kotlin 라이브러리(`neki.kotlin.library`)에서 Android 라이브러리(`neki.android.library`)로 모듈 타입을 변경하고, 이에 따라 `namespace`를 설정합니다.
[refactor] 업로드 티켓 발급 API 분리
- 기존 `getUploadTicket`을 `getMultipleUploadTicket`으로 변경합니다.
- 단일 파일 업로드를 위한 `getSingleUploadTicket` 메서드를 추가합니다.

[feat] 이미지 업로드 Repository 기능 추가
- `Uri` 또는 `String` URL을 통해 이미지를 직접 업로드할 수 있는 `uploadImageFromUri`와 `uploadImageFromUrl` 메서드를 `MediaUploadRepository`에 추가합니다.
`UploadMultiplePhotoUseCase`를 구현하여 여러 장의 사진을 한 번에 업로드하는 기능을 추가합니다.

이 UseCase는 다음의 순서로 동작합니다.
1.  업로드할 이미지 개수만큼 업로드 티켓을 한 번에 발급받습니다.
2.  각 이미지를 발급받은 Presigned URL을 통해 업로드합니다.
3.  업로드가 완료된 모든 사진을 서버에 등록합니다.
`MediaUploadRepository`에 Uri 또는 Url로부터 이미지를 업로드하는 기능을 추가합니다.

- `uploadImageFromUri`: `Uri`를 `ByteArray`로 변환하여 업로드합니다.
- `uploadImageFromUrl`: 이미지 `Url`을 `ByteArray`로 변환하여 업로드합니다.
- `ContentType`을 `Bitmap.CompressFormat`으로 변환하는 확장 함수를 추가합니다.
`UploadSinglePhotoUseCase`에서 이미지를 업로드할 때 `ByteArray`를 직접 전달하는 대신 이미지 `URL`을 사용하도록 수정합니다.
이에 따라 `getUploadTicket`은 `getSingleUploadTicket`으로, `uploadImage`는 `uploadImageFromUrl`로 변경되었습니다.
`UploadMultiplePhotoUseCase`를 추가하고, 여러 장의 이미지 Uri를 서버에 업로드하는 기능을 구현합니다.
기존 `UploadType`을 `SINGLE`과 `MULTIPLE`로 변경하여, 단일/다중 이미지 업로드 분기 처리를 명확하게 합니다.
폴더 목록을 조회하고, 새로운 폴더를 생성하는 기능을 위한 API 연동 코드를 추가합니다.

- `FolderService`에 폴더 목록 조회(`getFolders`) 및 폴더 생성(`createFolder`) API 함수를 정의합니다.
- `FolderRepository` 인터페이스와 그 구현체인 `FolderRepositoryImpl`을 추가하여 데이터 계층을 정의합니다.
- 폴더 생성 요청(`CreateFolderRequest`), 폴더 생성 응답(`CreateFolderResponse`), 폴더 목록 응답(`FolderResponse`)에 사용될 데이터 모델을 추가합니다.
보관함 화면 진입 시 즐겨찾기, 전체 사진과 함께 앨범 목록을 조회하도록 `fetchFolders` 함수를 추가하고 `init` 블록에서 호출합니다.
`folderRepository.createFolder()`를 호출하여 사용자가 입력한 이름으로 새 앨범(폴더)을 생성하는 기능을 구현합니다.

앨범 생성이 성공하면 폴더 목록을 다시 불러와 화면을 갱신합니다.
생성 실패 시에는 에러 로그를 기록합니다.
기존에 사용하던 더미 데이터를 제거하고, `FolderRepository`와 `PhotoRepository`를 사용하여 실제 앨범 및 즐겨찾는 사진 요약 정보를 가져오도록 `AllAlbumViewModel`을 수정합니다.
- `PhotoRepository`와 `FolderRepository`를 사용하여 즐겨찾기 앨범 정보와 전체 앨범 목록을 가져오도록 구현합니다.
- 로딩 상태를 관리하기 위해 `isLoading` 상태를 추가하고, 데이터 fetch 시 `isLoading`을 true/false로 업데이트합니다.
- `Album` 모델을 `AlbumPreview` 모델로 변경하여 화면에 필요한 최소한의 데이터만 사용하도록 수정합니다.
[refactor] ViewModel에서 더미 데이터 제거 및 API 연동 로직 추가

기존에 사용하던 `dummyPhotos`를 제거하고, `photoRepository.getPhotos()`를 호출하여 실제 사진 목록을 가져오도록 수정합니다.
Paging3 라이브러리를 사용하여 사진 목록을 불러오는 `PagingSource`를 구현합니다.

- `PhotoPagingSource`: 폴더 ID를 기반으로 사진 목록을 페이징합니다.
- `FavoritePhotoPagingSource`: 즐겨찾기한 사진 목록을 정렬 순서에 따라 페이징합니다.
Paging의 `initialLoadSize`를 `PAGE_SIZE`로 설정하여 초기 로딩 성능을 개선합니다.
앨범 상세 화면으로 이동할 때 `albumId`만 전달하던 방식에서 `AlbumPreview` 객체 전체를 전달하도록 수정합니다. 이를 통해 앨범 제목(`title`)을 상세 화면 ViewModel에 직접 주입할 수 있게 되어, 불필요한 데이터 로딩 과정을 제거하고 화면 간 데이터 전달 방식을 일관성 있게 개선합니다.

- `navigateToAlbumDetail` 함수의 파라미터에 `title`을 추가합니다.
- 앨범 아이템 클릭 시 `albumId` 대신 `AlbumPreview` 객체를 전달하도록 `ClickAlbumItem` 인텐트를 수정합니다.
- `AlbumDetailViewModel` 생성 시 `title`을 주입받아 초기 상태를 설정합니다.
- `folderRepository.createFolder` API를 호출하여 새로운 앨범을 생성하는 기능을 구현합니다.
- API 호출 성공 시, 앨범 목록을 갱신하고 "새로운 앨범을 추가했어요"라는 토스트 메시지를 표시합니다.
- API 호출 실패 시, "앨범 추가에 실패했어요"라는 토스트 메시지를 표시하고 에러 로그를 기록합니다.
폴더 생성 API의 응답 타입이 `BasicResponse`에서 `BasicNullableResponse`로 변경되었습니다.
이에 따라 `FolderRepository`의 `createFolder` 함수의 반환 타입을 `Result<Long>`에서 `Result<Unit>`으로 수정하여, 더 이상 폴더 ID를 반환하지 않도록 변경했습니다.
선택한 앨범을 삭제하는 기능을 구현합니다.

- `FolderService`에 `deleteFolder` API 함수를 추가합니다.
- `FolderRepository` 및 `FolderRepositoryImpl`에 `deleteFolder` 함수를 추가하고, `DeleteFolderRequest`를 사용하여 API를 호출하도록 구현합니다.
- `AllAlbumViewModel`에서 선택된 앨범들의 ID를 사용하여 `folderRepository.deleteFolder`를 호출하고, 성공 시 앨범 목록을 다시 불러오도록 수정합니다.
사진 그리드 레이아웃에 사용되는 상수의 이름을 더 명확하게 변경하고 이를 각 화면에 적용합니다.

- `ARCHIVE_LAYOUT_HORIZONTAL_PADDING` -> `PHOTO_GRID_LAYOUT_HORIZONTAL_PADDING`
- `ARCHIVE_LAYOUT_BOTTOM_PADDING` -> `PHOTO_GRAY_LAYOUT_BOTTOM_PADDING`
- `PHOTO_GRID_LAYOUT_TOP_PADDING` 상수 추가
전체 사진 화면에 정렬 순서(최신순, 오래된순) 및 즐겨찾기 필터링 기능을 추가합니다.

- `PhotoService`와 `PhotoPagingSource`에 `sortOrder` 파라미터를 추가하여 API 요청 시 정렬 순서를 전달합니다.
- `AllPhotoViewModel`에서 `flatMapLatest`를 사용해 필터(`_photoFilter`, `_isFavoriteOnly`) 변경 시 `photoPagingData`가 새로 발행되도록 수정했습니다.
- 필터가 변경되면 화면이 최상단으로 스크롤되도록 `ScrollToTop` 사이드 이펙트를 처리합니다.
- 스크롤 로직을 개선하여, 페이징 데이터 로드가 완료된 후 스크롤이 실행되도록 보장합니다.
사진 삭제 API 호출에 실패했을 경우, 로딩 상태만 `false`로 변경되고 확인 다이얼로그나 바텀시트가 열린 채로 유지되던 문제를 수정합니다.
삭제 실패 시 `isShowDeleteDialog`와 `isShowDeleteBottomSheet` 상태를 `false`로 변경하여 정상적으로 닫히도록 수정했습니다.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 29, 2026

Walkthrough

페이징 추가, 폴더 CRUD API/모델/리포지토/DI 추가, 미디어 업로드 API 확장(단/다중 티켓, URI/URL 업로드) 및 다중 사진 업로드 유스케이스 도입, UI 전반 Album→AlbumPreview 전환 및 네비게이션에 앨범 제목 추가, ArchiveResult로 결과 전파.

Changes

Cohort / File(s) Summary
빌드/라이브러리
gradle/libs.versions.toml, core/data-api/build.gradle.kts, core/data/build.gradle.kts, feature/archive/impl/build.gradle.kts
Paging 라이브러리(paging 3.3.2) 및 gradle 설정/플러그인 alias 변경, paging runtime/compose 의존성 추가.
페이징 소스 및 리포지토리
core/data/src/main/java/.../paging/PhotoPagingSource.kt, core/data/src/main/java/.../paging/FavoritePhotoPagingSource.kt, core/data-api/src/main/java/.../PhotoRepository.kt, core/data/src/main/java/.../repository/impl/PhotoRepositoryImpl.kt
Photo/Favorite용 PagingSource 추가 및 Repository에 Flow<PagingData> API와 Pager 구성 구현 추가.
폴더 관리 (API·모델·리포지토리·DI)
core/data/src/main/java/.../remote/api/FolderService.kt, core/data/src/main/java/.../remote/model/request/CreateFolderRequest.kt, .../DeleteFolderRequest.kt, .../response/CreateFolderResponse.kt, .../response/FolderResponse.kt, core/data-api/src/main/java/.../FolderRepository.kt, core/data/src/main/java/.../repository/impl/FolderRepositoryImpl.kt, core/data/src/main/java/.../repository/di/RepositoryModule.kt
폴더 조회/생성/삭제용 HTTP 서비스 및 요청·응답 모델 추가, FolderRepository 인터페이스·구현·Dagger 바인딩 추가.
미디어 업로드 API 및 구현
core/data-api/src/main/java/.../MediaUploadRepository.kt, core/data/src/main/java/.../repository/impl/MediaUploadRepositoryImpl.kt
업로드 티켓을 단일/다중 API로 분리, uploadImageFromUri/uploadImageFromUrl 추가, Context 주입 및 ContentType→CompressFormat 헬퍼 등 바이트 변환 로직 추가.
도메인 유스케이스
core/domain/src/main/java/.../UploadMultiplePhotoUseCase.kt, core/domain/src/main/java/.../UploadSinglePhotoUseCase.kt
UploadMultiplePhotoUseCase 추가(다중 티켓 요청·병렬 업로드·등록), UploadSinglePhotoUseCase 입력이 imageBytes→imageUrl로 변경.
모델/컴포넌트 변경
core/model/src/main/java/.../UploadType.kt, core/ui/src/main/java/.../AlbumRowComponent.kt
UploadType enum 변경(QR_SCAN→QR_CODE), UI 컴포넌트 파라미터 Album→AlbumPreview로 변경 및 관련 코드 조정.
아카이브/업로드 UI·네비게이션
feature/archive/api/.../ArchiveNavKey.kt, 다수의 feature/archive/impl/..., feature/photo-upload/impl/...
AlbumDetail에 title 필드 추가 및 네비게이션 서명 변경, 화면/뷰모델/계약서 전반에서 Album→AlbumPreview 전환, 페이징 도입, ArchiveResult 기반 결과 전파로 라우팅 수정.
상태·뷰모델 페이징 통합
feature/archive/impl/src/.../AllPhotoViewModel.kt, AlbumDetailViewModel.kt, AllAlbumViewModel.kt, ArchiveMainViewModel.kt, UploadAlbumViewModel.kt
PhotoRepository/FolderRepository 주입, paging 파이프라인(photoPagingData) 구성, deletedPhotoIds/updatedFavorites 등 삭제·즐겨찾기 변경 추적 상태 추가 및 기존 더미 데이터 제거.
결과 타입 및 사이드이펙트 변경
feature/archive/api/src/.../ArchiveResult.kt, feature/archive/impl/.../photo_detail/PhotoDetailContract.kt, .../PhotoDetailViewModel.kt, .../PhotoDetailScreen.kt
ArchiveResult(사진삭제/즐겨찾기 변경) 도입, PhotoDetail의 결과 사이드 이펙트가 ArchiveResult 페이로드를 전달하도록 변경.

Sequence Diagram(s)

sequenceDiagram
    participant User as 사용자
    participant VM as ArchiveMainViewModel
    participant UC as UploadMultiplePhotoUseCase
    participant MediaRepo as MediaUploadRepository
    participant UploadService as UploadService
    participant PhotoRepo as PhotoRepository
    participant API as 원격API

    User->>VM: 여러 사진 선택 후 업로드 요청
    VM->>UC: invoke(imageUris, contentType, folderId)
    UC->>MediaRepo: getMultipleUploadTicket(count,fileName,...)
    MediaRepo->>UploadService: 요청 (티켓 생성)
    UploadService->>API: POST /api/upload/tickets
    API-->>UploadService: presigned URL 목록
    UploadService-->>MediaRepo: 티켓 반환
    par 병렬 업로드
      UC->>MediaRepo: uploadImageFromUri(presignedUrl, uri)
      MediaRepo->>API: PUT presigned-url (병렬)
      API-->>MediaRepo: 업로드 응답
    end
    UC->>PhotoRepo: registerPhotos(mediaIds, folderId)
    PhotoRepo->>API: POST /api/photos/register
    API-->>PhotoRepo: 등록 완료
    PhotoRepo-->>UC: 등록된 Media 리스트
    UC-->>VM: Result<List<Media>>
Loading
sequenceDiagram
    participant User as 사용자
    participant UI as AllPhotoScreen
    participant VM as AllPhotoViewModel
    participant Repo as PhotoRepository
    participant Pager as Pager/PhotoPagingSource
    participant Service as PhotoService
    participant API as 원격API

    UI->>VM: 화면 진입 -> getPhotosFlow()
    VM->>Repo: getPhotosFlow()
    Repo->>Pager: Pager 생성
    UI->>Pager: 스크롤 -> 다음 페이지 요청
    Pager->>Service: getPhotos(page=N, sortOrder)
    Service->>API: GET /api/photos?page=N&sortOrder=...
    API-->>Service: PhotoResponse
    Service-->>Pager: 응답 전달
    Pager-->>UI: PagingData 방출
    UI->>User: 그리드 업데이트
Loading

🎯 Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

📌 Possibly related PRs

🏷️ Suggested labels

feat

👥 Suggested reviewers

  • Ojongseok

Poem

🥕 토끼가 말하네, 폴더도 만들고 사진도 쭉쭉!
여러 장은 병렬로, 한 장은 URL로 척척!
페이징은 부드럽게, 미리보기는 반짝반짝 ✨
제목도 같이 전해줘 — 네비게이션이 살았다! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 핵심 변경사항을 명확하게 요약하고 있으며, 연결된 이슈 번호(#59, #64, #69)를 포함하여 구체적이고 관련성이 높습니다.
Linked Issues check ✅ Passed PR의 모든 코드 변경이 링크된 이슈의 요구사항을 충족합니다: 페이징 구현(#59), 다중 사진 업로드(#64), 폴더 API 연동(#69)이 모두 구현되었습니다.
Out of Scope Changes check ✅ Passed PR의 모든 변경사항이 #59, #64, #69 이슈의 범위 내에 있으며, 스코프 외의 변경사항은 발견되지 않습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (3)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (1)

171-205: 빈 선택 상태에서 삭제 API 호출 가능

선택이 비어 있는 상태로 확인 버튼이 눌리면 빈 리스트로 삭제 호출이 나갈 수 있습니다. 방어 로직을 추가해 주세요.

🛡️ 방어 로직 추가 제안
 private fun handleFavoriteDelete(
     state: AlbumDetailState,
     reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit,
     postSideEffect: (AlbumDetailSideEffect) -> Unit,
 ) {
     val selectedPhotoIds = state.selectedPhotos.map { it.id }
+    if (selectedPhotoIds.isEmpty()) {
+        postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 선택해주세요."))
+        reduce { copy(isShowDeleteDialog = false) }
+        return
+    }

     viewModelScope.launch {
         reduce { copy(isLoading = true) }
         photoRepository.deletePhoto(photoIds = selectedPhotoIds)
             .onSuccess {
                 ...
             }
             .onFailure { error ->
                 ...
             }
     }
 }
 private fun handleAlbumPhotoDelete(
     state: AlbumDetailState,
     reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit,
     postSideEffect: (AlbumDetailSideEffect) -> Unit,
 ) {
     val selectedPhotoIds = state.selectedPhotos.map { it.id }
+    if (selectedPhotoIds.isEmpty()) {
+        postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 선택해주세요."))
+        reduce { copy(isShowDeleteBottomSheet = false) }
+        return
+    }

     viewModelScope.launch {
         reduce { copy(isLoading = true) }
         photoRepository.deletePhoto(photoIds = selectedPhotoIds)
             .onSuccess {
                 ...
             }
             .onFailure { error ->
                 ...
             }
     }
 }

Also applies to: 208-242

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumScreen.kt (1)

117-145: existingAlbumNamesuiState.albums 변경 시 업데이트되지 않을 수 있습니다.

remember에 key가 없어서 uiState.albums가 변경되어도 existingAlbumNames가 갱신되지 않습니다. 새 앨범이 추가된 후 bottom sheet가 다시 열리면 중복 검증이 실패할 수 있습니다.

🐛 수정 제안
-        val existingAlbumNames = remember { uiState.albums.map { it.title } }
+        val existingAlbumNames = remember(uiState.albums) { uiState.albums.map { it.title } }
feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt (1)

60-70: 앨범 선택 해제 로직에서 객체 동등성 불일치로 인한 버그

AlbumPreview는 Kotlin data class이므로 자동 생성된 equals() 메서드가 모든 속성(id, title, thumbnailUrl, photoCount)을 비교합니다. 그러나 Line 63에서는 it.id == intent.album.id로 id만 비교하고, Line 64의 selectedAlbums.remove(intent.album)는 전체 속성이 일치해야 작동합니다. 같은 album id를 가진 객체라도 다른 API 응답에서 온 경우 다른 메타데이터를 가질 수 있어, id는 일치하지만 객체는 다르게 인식되어 제거되지 않을 수 있습니다.

🐛 id 기반 필터링을 사용한 수정 제안
 is UploadAlbumIntent.ClickAlbumItem -> {
     reduce {
         copy(
-            selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) {
-                selectedAlbums.remove(intent.album)
+            selectedAlbums = if (selectedAlbums.any { it.id == intent.album.id }) {
+                selectedAlbums.filter { it.id != intent.album.id }
             } else {
                 selectedAlbums.add(intent.album)
             }.toPersistentList(),
         )
     }
 }
🤖 Fix all issues with AI agents
In
`@core/data/src/main/java/com/neki/android/core/data/paging/PhotoPagingSource.kt`:
- Around line 38-40: PhotoPagingSource의 load(...)에서 모든 Exception을 잡는 catch 블록이
CancellationException까지 잡아 코루틴 취소가 전파되지 않으니, catch (e: Exception) 내부에서
CancellationException을 재전파하거나 별도 catch (e: CancellationException)로 먼저 처리하도록
변경하세요; 구체적으로 PhotoPagingSource의 load 함수에서 현재 catch (e: Exception) {
LoadResult.Error(e) }를 수정해 CancellationException인 경우 throw e를 수행하고 그 외에는 기존대로
LoadResult.Error(e)를 반환하도록 하세요.

In
`@core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt`:
- Around line 40-51: The code in UploadMultiplePhotoUseCase uses tickets[index]
inside the coroutineScope imageUris.mapIndexed block which can throw
IndexOutOfBoundsException if tickets is shorter than imageUris; fix by using
safe access (tickets.getOrNull(index)) and handle the missing ticket
explicitly—either fail fast with a clear exception (e.g., throw
IllegalStateException("Missing upload ticket for index $index")) or filter/zip
imageUris with tickets before launching coroutines (e.g., zip imageUris with
tickets to produce pairs) and then call mediaUploadRepository.uploadImageFromUri
for each pair so you never directly index into tickets.

In
`@feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt`:
- Around line 20-24: AlbumDetail 데이터 클래스의 새 필드 title에 기본값을 추가해 역직렬화 호환성을 보장하세요:
AlbumDetail 클래스(구체적으로 title 프로퍼티)에 기본값(예: 빈 문자열) 을 설정해 이전에 저장된 NavKey를 복원할 때
MissingFieldException이 발생하지 않도록 수정하고, 필요한 경우 ArchiveNavKey를 사용하는 곳에서 생성 호출이 기본값과
호환되는지 확인하세요.

In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt`:
- Around line 154-156: The local declaration val album in the
SelectMode.SELECTING branch shadows the function parameter album: AlbumPreview;
rename the local variable returned by state.albums.find (e.g., foundAlbum or
existingAlbum) and update all subsequent uses in that branch (for example change
state.albums.find { it.id == album.id } to state.albums.find { it.id == album.id
} -> val foundAlbum = ... and then use foundAlbum.id when computing isSelected
and other places) so the parameter and local variable are distinct and no longer
shadow each other.

In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainContract.kt`:
- Around line 22-24: 현재 uploadType 프로퍼티는 selectedUris.size == 1이면 SINGLE로 판정해
갤러리 URI 선택 시 uploadWithoutAlbum에서 state.scannedImageUrl ?: return으로 조용히 종료되는 문제가
있습니다; 고치려면 uploadType의 SINGLE 분기를 scannedImageUrl != null으로 제한하거나(즉 val
uploadType: UploadType get() = if (scannedImageUrl != null) UploadType.SINGLE
else UploadType.MULTIPLE), 아니면 uploadWithoutAlbum 함수 내부에서 uploadType 대신
state.scannedImageUrl 여부로 분기하여 scannedImageUrl가 null인 경우에는 selectedUris 단일 항목을
업로드하도록 처리하세요; 관련 식별자: uploadType 프로퍼티, uploadWithoutAlbum, uploadWithAlbumRow,
state.scannedImageUrl, selectedUris.

In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt`:
- Around line 66-67: Remove the debug log call Timber.d(pagingItems.toString())
from AllPhotoScreen (where pagingItems is assigned via
viewModel.photoPagingData.collectAsLazyPagingItems()); simply delete that
Timber.d(...) line so no debug-only logging remains in the production UI code.

In
`@feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt`:
- Line 113: The upload handler in UploadAlbumViewModel currently returns
silently when state.selectedAlbums is empty (val firstAlbum =
state.selectedAlbums.firstOrNull() ?: return), causing no user feedback; change
this to emit a user-facing event instead of returning silently—for example,
replace the early return with code that posts a Toast/ Snackbar UI event (via
the ViewModel's existing UI event channel or LiveData) like
emit(UiEvent.ShowToast("Please select an album")) or alternatively ensure the
upload button is disabled in the UI until state.selectedAlbums is non-empty;
update the upload method that contains the firstAlbum line and any related UI
event types so the view can display the message.

In `@gradle/libs.versions.toml`:
- Line 37: Update the paging library version from 3.3.2 to the latest stable
3.3.6 by changing the paging version entry (paging = "3.3.2") to paging =
"3.3.6" in libs.versions.toml, and verify that any module references using that
version (e.g., dependencies referencing paging-runtime and paging-compose)
continue to use the updated version variable so both paging-runtime and
paging-compose are bumped to 3.3.6.
🧹 Nitpick comments (15)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt (1)

8-18: deletedPhotoIds도 ImmutableSet으로 고정해 상태 불변성을 유지하세요.
현재 Set<Long>는 가변 구현이 섞일 여지가 있어 MVI 상태 일관성이 깨질 수 있습니다. 다른 상태 필드와 동일하게 immutable 컬렉션으로 맞추는 편이 안전합니다.

♻️ 제안 수정
 import com.neki.android.core.model.Photo
 import com.neki.android.feature.archive.impl.model.SelectMode
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentSetOf

 data class AlbumDetailState(
     val isLoading: Boolean = false,
     val title: String = "",
     val isFavoriteAlbum: Boolean = false,
     val selectMode: SelectMode = SelectMode.DEFAULT,
     val selectedPhotos: ImmutableList<Photo> = persistentListOf(),
-    val deletedPhotoIds: Set<Long> = emptySet(),
+    val deletedPhotoIds: ImmutableSet<Long> = persistentSetOf(),
     val isShowDeleteDialog: Boolean = false,
     val isShowDeleteBottomSheet: Boolean = false,
     val selectedDeleteOption: PhotoDeleteOption = PhotoDeleteOption.REMOVE_FROM_ALBUM,
 )
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailScreen.kt (2)

152-165: Staggered grid에서 로딩 인디케이터가 한 컬럼에만 표시됩니다.

LazyVerticalStaggeredGrid에서 item { } 블록은 단일 셀만 차지합니다. StaggeredGridCells.Fixed(2) 설정으로 2열 그리드이므로, 로딩 인디케이터가 한쪽 컬럼에만 표시되어 시각적으로 중앙 정렬되지 않습니다.

♻️ 전체 너비에 로딩 인디케이터를 표시하는 방법

fullSpan을 사용하여 두 컬럼을 모두 차지하도록 수정:

 if (pagingItems.loadState.append is LoadState.Loading) {
-    item {
+    item(span = StaggeredGridItemSpan.FullLine) {
         Box(
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(16.dp),
             contentAlignment = Alignment.Center,
         ) {
             CircularProgressIndicator(
                 color = NekiTheme.colorScheme.primary500,
             )
         }
     }
 }

추가로 import 필요:

import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan

96-97: 페이징 에러 상태에 대한 처리가 누락되었습니다.

isRefreshingisEmpty는 처리되지만, LoadState.Error 상태에 대한 UI 피드백이 없습니다. 네트워크 오류 등 발생 시 사용자에게 적절한 안내가 필요합니다.

♻️ 에러 상태 처리 추가 제안
 val isRefreshing = pagingItems.loadState.refresh is LoadState.Loading
 val isEmpty = pagingItems.itemCount == 0 && pagingItems.loadState.refresh is LoadState.NotLoading
+val refreshError = pagingItems.loadState.refresh as? LoadState.Error

그리고 UI에서 에러 상태를 표시하거나, 토스트 메시지를 보여주는 방식으로 처리할 수 있습니다:

refreshError?.let { error ->
    // 에러 UI 표시 또는 재시도 버튼 제공
    // nekiToast.showToast(text = "데이터를 불러오지 못했어요")
}
feature/archive/api/src/main/kotlin/com/neki/android/feature/archive/api/ArchiveNavKey.kt (1)

42-43: AlbumDetail 생성 시 named args 사용 권장

Line 43에서 위치 인자 사용은 필드 추가/순서 변경 시 실수 가능성이 있어요. 가독성과 안전성을 위해 named args로 명시해 주세요.

🔧 제안 수정안
 fun Navigator.navigateToAlbumDetail(id: Long, title: String = "", isFavorite: Boolean = false) {
-    navigate(ArchiveNavKey.AlbumDetail(isFavorite, title, id))
+    navigate(
+        ArchiveNavKey.AlbumDetail(
+            isFavorite = isFavorite,
+            title = title,
+            albumId = id,
+        )
+    )
 }
core/data/src/main/java/com/neki/android/core/data/remote/api/PhotoService.kt (1)

58-67: sortOrder 기본값 일관성 검토.

getPhotossortOrder에 기본값 "DESC"가 있지만, getFavoritePhotos는 기본값이 없습니다. 서비스 레이어에서 일관성을 위해 동일한 패턴을 적용하는 것을 고려해 보세요.

♻️ 제안된 수정
     suspend fun getFavoritePhotos(
         page: Int = 0,
         size: Int = 20,
-        sortOrder: String,
+        sortOrder: String = "DESC",
     ): BasicResponse<FavoritePhotoResponse> {
core/data/src/main/java/com/neki/android/core/data/repository/impl/MediaUploadRepositoryImpl.kt (2)

22-38: first() 호출 시 빈 리스트 처리 확인.

서버 응답이 빈 리스트를 반환할 경우 first()NoSuchElementException을 던집니다. runSuspendCatching이 이를 처리하겠지만, 보다 명확한 에러 메시지를 위해 firstOrNull()과 명시적 에러 처리를 고려해 보세요.

♻️ 제안된 수정
-        ).data.toModels().first()
+        ).data.toModels().firstOrNull()
+            ?: error("No upload ticket received from server")
     }

71-88: Dispatchers.Default 사용에 대한 검토.

uri.toByteArray()ContentResolver를 통한 I/O 읽기와 비트맵 압축(CPU 작업)을 모두 포함합니다. 현재 Dispatchers.Default는 CPU 집약적 작업에 적합하지만, I/O 작업도 포함되어 있어 Dispatchers.IO가 더 적절할 수 있습니다.

♻️ 제안된 수정
-        val imageBytes = withContext(Dispatchers.Default) {
+        val imageBytes = withContext(Dispatchers.IO) {
             uri.toByteArray(
core/data-api/src/main/java/com/neki/android/core/dataapi/repository/FolderRepository.kt (1)

5-8: 매개변수 명칭을 folderIds로 통일해 명확성 개선 제안
리스트 타입인데 id는 단수로 보입니다. DeleteFolderRequest.folderIds와도 용어가 불일치합니다.

♻️ 제안 변경
-    suspend fun deleteFolder(id: List<Long>): Result<Unit>
+    suspend fun deleteFolder(folderIds: List<Long>): Result<Unit>
core/data/src/main/java/com/neki/android/core/data/remote/model/response/CreateFolderResponse.kt (1)

6-6: Line 6 TODO 추적 필요
릴리즈 전에 제거될 계획이라면 이슈/티켓으로 추적해 누락되지 않도록 해주세요.

원하시면 TODO 추적용 이슈 템플릿/초안을 만들어 드릴까요?

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt (2)

95-113: 코드 중복: fetchFavoriteSummary/fetchFolders

fetchFavoriteSummaryfetchFolders 함수가 ArchiveMainViewModelUploadAlbumViewModel과 동일한 패턴으로 중복되어 있습니다. 이 로직을 공통 유틸리티나 base class로 추출하면 유지보수성이 향상될 수 있습니다.


194-194: TODO 코멘트: 삭제 타입 핸들링 미구현

삭제 타입에 따른 핸들링이 TODO로 남아 있습니다. 이 기능 구현이 필요한 경우 별도 이슈로 트래킹하시겠습니까?

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt (1)

158-166: 코드 중복: fetchFolders 함수가 여러 ViewModel에서 반복됩니다.

fetchFolders 구현이 AllAlbumViewModel, UploadAlbumViewModel, 그리고 이 파일에서 거의 동일하게 반복됩니다. 공통 유틸리티나 base class로 추출하는 것을 고려해 보세요.

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoViewModel.kt (1)

54-59: photoPagingDatacachedIn이 적용되지 않았습니다.

originalPagingDatacachedIn(viewModelScope)이 적용되어 있지만, combine으로 생성된 photoPagingData는 캐싱되지 않습니다. 여러 collector가 있거나 configuration change 시 불필요한 재조회가 발생할 수 있습니다.

♻️ 수정 제안
 val photoPagingData: Flow<PagingData<Photo>> = combine(
     originalPagingData,
     deletedPhotoIds,
 ) { pagingData, deletedIds ->
     pagingData.filter { photo -> photo.id !in deletedIds }
-}
+}.cachedIn(viewModelScope)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt (1)

233-239: Preview가 비어있습니다.

Paging 기반 화면이라 Preview 구현이 어렵지만, flowOf(PagingData.from(listOf(...)))를 사용하여 mock 데이터로 Preview를 제공할 수 있습니다. 현재 상태로도 기능에는 문제없습니다.

feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt (1)

88-106: 에러 발생 시 사용자 피드백 고려

fetchFavoriteSummaryfetchFolders 모두 실패 시 Timber 로그만 남기고 사용자에게는 피드백이 없습니다. 데이터 로딩 실패 시 사용자가 상황을 인지할 수 있도록 토스트 메시지나 에러 상태 표시를 고려해 보세요.

Comment thread gradle/libs.versions.toml
포토카드 상세 화면(`PhotoDetailScreen`)에서 변경 사항이 발생했을 때 상위 화면으로 전달하는 결과 타입을 `Boolean`에서 `ArchiveResult`로 변경합니다.
`ArchiveResult`는 `PhotoDeleted`와 `FavoriteChanged` 두 가지 상태를 가지며, 각각 삭제된 사진의 ID와 즐겨찾기 상태가 변경된 사진의 ID 및 상태를 포함합니다.
이를 통해 아카이브 메인, 전체 사진, 앨범 상세 화면 등에서 개별 아이템의 상태를 더 명확하게 갱신할 수 있도록 수정했습니다.
앨범 추가 바텀시트에서 기존 앨범 이름 목록을 가져오는 `remember`에 `uiState.albums`를 키로 추가합니다. 이를 통해 앨범 목록이 변경될 때마다 중복 검사를 위한 이름 목록이 올바르게 갱신되도록 수정합니다.
QR 코드 스캔을 통한 업로드와 갤러리 선택을 통한 업로드를 명확하게 구분하기 위해 `UploadType`의 로직을 수정합니다.

- `UploadType.SINGLE`과 `UploadType.MULTIPLE`을 `UploadType.QR_CODE`와 `UploadType.GALLERY`로 변경하여 업로드 출처를 명확히 합니다.
- `scannedImageUrl`이 있을 경우 `QR_CODE`로, 그렇지 않으면 `GALLERY`로 업로드 타입을 결정하도록 수정했습니다.
- AllPhoto/AlbumDetail에 PhotoDeleted, FavoriteChanged Intent 추가
- PagingData에 updatedFavorites 적용하여 즐겨찾기 상태 반영
- ResultEffect로 ArchiveResult 수신 후 ViewModel Intent 전달
QR 코드 스캔을 통한 업로드와 갤러리 선택을 통한 업로드를 명확하게 구분하기 위해 `UploadType`의 로직을 수정합니다.

- `UploadType.SINGLE`과 `UploadType.MULTIPLE`을 `UploadType.QR_CODE`와 `UploadType.GALLERY`로 변경하여 업로드 출처를 명확히 합니다.
- `scannedImageUrl`이 있을 경우 `QR_CODE`로, 그렇지 않으면 `GALLERY`로 업로드 타입을 결정하도록 수정했습니다.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailContract.kt`:
- Line 14: AlbumDetailState.deletedPhotoIds가 현재 실제로 동기화되지 않는 데드 코드이므로, 삭제된 ID를
관리하는 ViewModel의 deletedPhotoIds MutableStateFlow와 일치시키거나 필드를 제거하세요; 구체적으로는
AlbumDetailState.deletedPhotoIds 선언을 제거하거나, 상태 업데이트가 발생하는 reduce() 블록(예: 이벤트 처리용
reduce()/reduceState() 등)에서 ViewModel의 deletedPhotoIds 값을 반영하도록 상태를 복제하여
deletedPhotoIds를 갱신하도록 추가하고 photoPagingData 필터링 로직과 일관되게 유지하세요.
🧹 Nitpick comments (4)
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (1)

225-259: 삭제 처리 로직 중복은 공통화 고려해볼 만합니다.
handleFavoriteDelete와 로직이 거의 동일하니 공통 함수로 추출하면 유지보수성이 좋아집니다.

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt (1)

244-255: 앨범 추가 후 bottom sheet 닫기 타이밍을 확인해 주세요.

reduce { copy(isShowAddAlbumBottomSheet = false) }onSuccess/onFailure 블록 밖에서 호출되어 API 호출 완료 전에 bottom sheet가 닫힐 수 있습니다. 의도된 UX라면 괜찮지만, 실패 시에도 즉시 닫히는 것이 사용자에게 혼란을 줄 수 있습니다.

♻️ 성공/실패 후 닫기로 변경하는 제안
     private fun handleAddAlbum(
         albumName: String,
         reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit,
         postSideEffect: (ArchiveMainSideEffect) -> Unit,
     ) {
         viewModelScope.launch {
             folderRepository.createFolder(name = albumName)
                 .onSuccess {
                     fetchFolders(reduce)
                     postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요"))
+                    reduce { copy(isShowAddAlbumBottomSheet = false) }
                 }
                 .onFailure { error ->
                     postSideEffect(ArchiveMainSideEffect.ShowToastMessage("앨범 추가에 실패했어요"))
                     Timber.e(error)
+                    reduce { copy(isShowAddAlbumBottomSheet = false) }
                 }
-            reduce { copy(isShowAddAlbumBottomSheet = false) }
         }
     }
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt (2)

166-166: 선택된 사진 검색 최적화를 고려해 보세요.

uiState.selectedPhotos.any { it.id == photo.id }는 각 아이템마다 O(n) 검색을 수행합니다. 선택된 사진이 많아지면 Set<Long>으로 변환하여 O(1) 검색으로 최적화할 수 있습니다.

♻️ Set을 사용한 최적화 제안
+    val selectedPhotoIds = remember(uiState.selectedPhotos) { 
+        uiState.selectedPhotos.map { it.id }.toSet() 
+    }
+
     // items 블록 내에서:
-    val isSelected = uiState.selectedPhotos.any { it.id == photo.id }
+    val isSelected = selectedPhotoIds.contains(photo.id)

233-236: Preview가 비어있는 상태입니다.

Paging 없이 Preview를 구현하기 어렵다는 점은 이해하지만, 최소한의 placeholder UI나 주석을 추가하면 Preview의 의도가 더 명확해집니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
feature/photo-upload/impl/src/main/java/com/neki/android/feature/photo_upload/impl/album/UploadAlbumViewModel.kt (1)

60-70: 앨범 토글 로직에서 remove 동작이 실패할 수 있습니다.

ID 비교로 존재 여부를 확인하지만(state.selectedAlbums.any { it.id == intent.album.id }), 제거 시에는 객체 동등성(remove(intent.album))에 의존합니다. AlbumPreview가 data class라도 다른 필드 값이 다르면 제거가 실패할 수 있습니다.

🐛 ID 기반 필터링으로 수정 제안
 is UploadAlbumIntent.ClickAlbumItem -> {
     reduce {
         copy(
-            selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) {
-                selectedAlbums.remove(intent.album)
-            } else {
-                selectedAlbums.add(intent.album)
-            }.toPersistentList(),
+            selectedAlbums = if (state.selectedAlbums.any { it.id == intent.album.id }) {
+                selectedAlbums.filter { it.id != intent.album.id }.toPersistentList()
+            } else {
+                selectedAlbums.add(intent.album)
+            },
         )
     }
 }

`ArchiveEntryProvider` 내에 개별적으로 존재하던 `archiveMainEntry`, `allPhotoEntry`, `allAlbumEntry`, `albumDetailEntry`, `photoDetailEntry` 함수들을 단일 `archiveEntry` 함수로 통합합니다.
- DELETE /api/folders에 deletePhotos 쿼리 파라미터 추가
- DELETE /api/folders/{folderId}/photos 폴더에서 사진 제거 API 추가
- AlbumDeleteOption에 따라 사진 포함/미포함 삭제 분기
- PhotoDeleteOption에 따라 앨범에서만 제거/완전 삭제 분기
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt`:
- Around line 110-115: The favorite-album navigation can produce a null title
(navigateToFavoriteAlbum -> navigator.navigateToAlbumDetail called without
title) which makes key.title nullable and can crash code that expects non-null
in the Factory; make this null-safe by either (A) supplying a non-null title
when calling navigateToAlbumDetail from navigateToFavoriteAlbum (e.g., use a
default or fetch the album title before navigation) or (B) update the Factory
and any consumers that read key.title to accept a nullable String and handle
null cases safely (e.g., fallback UI/text or guard clauses). Locate references
to navigateToFavoriteAlbum, navigateToAlbumDetail, key.title and the Factory to
implement the chosen fix so no non-null assertion on title remains.
🧹 Nitpick comments (4)
core/data/src/main/java/com/neki/android/core/data/repository/impl/FolderRepositoryImpl.kt (1)

19-23: 생성된 폴더 ID 반환 고려

createFolderResult<Unit>을 반환하지만, CreateFolderResponse에 생성된 폴더의 ID가 포함되어 있다면 이를 반환하는 것이 향후 유용할 수 있습니다. 현재 사용처에서는 폴더 목록을 다시 조회하므로 당장 문제는 없지만, 추후 개선 시 참고해 주세요.

feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt (1)

175-186: Bottom sheet 닫기 타이밍 개선 고려

현재 네트워크 호출 완료 후 bottom sheet를 닫고 있어, 느린 네트워크에서 사용자가 대기해야 합니다. 버튼 클릭 즉시 bottom sheet를 닫고 로딩 인디케이터를 표시하는 방식을 고려해 보세요.

♻️ 개선 제안
     private fun handleAddAlbum(
         albumName: String,
         reduce: (AllAlbumState.() -> AllAlbumState) -> Unit,
         postSideEffect: (AllAlbumSideEffect) -> Unit,
     ) {
         viewModelScope.launch {
+            reduce { copy(isShowAddAlbumBottomSheet = false, isLoading = true) }
             folderRepository.createFolder(name = albumName)
                 .onSuccess {
                     fetchFolders(reduce)
                     postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요"))
                 }
                 .onFailure { error ->
                     postSideEffect(AllAlbumSideEffect.ShowToastMessage("앨범 추가에 실패했어요"))
                     Timber.e(error)
                 }
-            reduce { copy(isShowAddAlbumBottomSheet = false) }
+            reduce { copy(isLoading = false) }
         }
     }
feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt (2)

82-84: EnterAlbumDetailScreen Intent 사용 여부 검토가 필요합니다.

페이징으로 전환 후 이 Intent가 no-op이 되었습니다. 화면 진입 시 별도 로직이 필요 없다면 Intent 자체를 제거하거나, 향후 필요한 초기화 로직(예: 분석 이벤트 전송)이 있다면 명시적으로 추가하는 것을 고려해 주세요.


190-267: handleFavoriteDeletehandleAlbumPhotoDelete 중복 로직 정리 고려.

두 함수의 구조가 매우 유사합니다 (로딩 상태 설정 → 저장소 호출 → 성공 시 상태 초기화 → 실패 시 에러 처리). 공통 로직을 추출하여 코드 중복을 줄일 수 있습니다.

♻️ 공통 삭제 로직 추출 예시
+    private fun executePhotoDelete(
+        selectedPhotoIds: List<Long>,
+        deleteOperation: suspend () -> Result<Unit>,
+        reduce: (AlbumDetailState.() -> AlbumDetailState) -> Unit,
+        postSideEffect: (AlbumDetailSideEffect) -> Unit,
+        dismissDialog: AlbumDetailState.() -> AlbumDetailState,
+    ) {
+        viewModelScope.launch {
+            reduce { copy(isLoading = true) }
+
+            deleteOperation()
+                .onSuccess {
+                    Timber.d("삭제 성공")
+                    deletedPhotoIds.update { it + selectedPhotoIds.toSet() }
+                    reduce {
+                        copy(
+                            selectedPhotos = persistentListOf(),
+                            selectMode = SelectMode.DEFAULT,
+                            isLoading = false,
+                        ).dismissDialog()
+                    }
+                    postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 삭제했어요"))
+                }
+                .onFailure { error ->
+                    Timber.e(error)
+                    reduce { copy(isLoading = false).dismissDialog() }
+                    postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진 삭제에 실패했어요"))
+                }
+        }
+    }

Copy link
Copy Markdown
Member

@Ojongseok Ojongseok left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정말 고생 많으셨습니다.

즐겨찾기와 사진삭제를 제외하고 모든 부분에서 일관되게 API 핸들링하고 있어 읽어나가기 수월했던 것 같습니다. 즐겨찾기/사진삭제 이벤트가 발생했을 때에도 ResultBus에 태워보내 PagingData를 갱신하는 것도 잘 보았습니다.

현재 API 스펙에 folder로 되어있는데 저희도 맞출까요?

UI에서 앨범이라는 워딩을 많이 사용하고 있고, API 명세에서는 폴더로 내려주지만 저희는 현행대로 앨범이라는 워딩을 그대로 사용해도 괜찮지 않을까 싶습니다.


Q1. 현재 domain 모듈에 data, data-api 두 모듈에 대한 참조를 갖고 있는데 문제 없을까요?
domain에서 data를 참조하는 이유가 runSuspendCatching을 사용하기 위함이라면 해당 함수의 경로를 바꾸는게 좋을까요?

Q2. 이번 PR에서 Album -> AlbumPreview data class로 많은 부분에서 변경해주셨던데 Album에서 thumbnailUrl을 포함할 수 없어서 별도로 분리하신건가요?

Comment on lines +71 to +88
override suspend fun uploadImageFromUri(
uploadUrl: String,
uri: Uri,
contentType: ContentType,
) = runSuspendCatching {
val imageBytes = withContext(Dispatchers.Default) {
uri.toByteArray(
context = context,
format = contentType.toCompressFormat(),
) ?: error("Failed to convert uri to byte array")
}

uploadService.uploadImage(
presignedUrl = uploadUrl,
imageBytes = imageBytes,
contentType = contentType.label,
)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uri 업로드 함수를 추가해주셔

suspend fun uploadImage(
    uploadUrl: String,
    imageBytes: ByteArray,
    contentType: ContentType,
): Result<Unit>

MediaUploadRepository 내 해당 인터페이스는 제거해도 되지 않을까요?

@Ojongseok Ojongseok merged commit 379a8be into develop Feb 2, 2026
4 of 5 checks passed
@ikseong00 ikseong00 deleted the feat/#59,64,69-archive-api branch February 5, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 폴더 조회 생성 API 구현 [feat] 여러개 사진 업로드 기능 구현 [refactor] 페이징 기능 구현

2 participants