Skip to content

[Feat] #202 - Sprint4 작업항목 플로우 구현, 인스타그램 스토리 공유#203

Open
OneTen19 wants to merge 18 commits intodevelopfrom
feat/#202-sprint4
Open

[Feat] #202 - Sprint4 작업항목 플로우 구현, 인스타그램 스토리 공유#203
OneTen19 wants to merge 18 commits intodevelopfrom
feat/#202-sprint4

Conversation

@OneTen19
Copy link
Copy Markdown
Member

@OneTen19 OneTen19 commented Apr 2, 2026

🌴 작업한 브랜치

  • feat/#202-sprint4

✅ 작업한 내용

사진 이동/복제 플로우와 사진 가져오기(Import) 기능을 구현하고, 전반적인 사용성을 높이기 위한 UI/UX 개선 작업을 진행했습니다.

  1. 사진 이동 및 복제 플로우 구현 (AlbumSelection)
  • 사진 이동 및 복제 시 앨범을 선택할 수 있는 재사용 가능한 AlbumSelectionView 및 AlbumSelectionFeature 리듀서 구현
  • ArchiveImageFooter를 상세 화면용(.detail)과 선택 화면용(.selection)으로 분기하여 재사용성 강화
  • PhotoDetail 뷰 및 다중 선택 모드에서 앨범 선택 뷰 연결
  1. 사진 가져오기 시트 구현 (PhotoImport)
  • 앨범 상세 화면에서 '사진 가져오기' 버튼 및 뷰 추가
  • 사용자가 업로드한 모든 사진을 불러와 선택할 수 있는 시트 UI 구현
  • 많은 사진을 원활하게 불러올 수 있도록 스크롤 하단 도달 시 무한 스크롤(Pagination) 기능 적용
  • 앨범 변경 시 선택된 사진 목록 초기화 및 부드러운 뷰 전환(Opacity/Cross-fade) 애니메이션 적용
  • 시트 하단 스크롤 비침을 방지하는 솔리드 배경 및 그라데이션 추가, 테두리 둥글기 등 디테일 수정
  1. 인스타그램 스토리 공유 및 부가 기능
  • 앨범 상세 화면 내 '앨범 삭제' 버튼 기능 연결
  • 다운로드한 사진을 인스타그램 스토리에 바로 공유할 수 있는 기능 추가 (#if DEBUG 로 내부 테스트에서만 체험 가능하게 구현했습니다.)

❗️PR Point

  • 앨범 내에 이미지가 있으면 삭제가 안 되고 있어요. 서버측에 문의 필요. 400뜸
  • 앨범이 비어있으면 잘 삭제됩니다. 200뜸
  • 애매한 것들이 좀 있어서 TODO로 해둔 것들이 좀 있어요. 문의 남기고 답변 오면 반영해야댐
  • API가 나오면 붙이기만 하면 될 정도로 작업해뒀습니다
  • 4차 스프린트 항목 중엔 GA 붙이기만 남았어요. 이건 나오면 하겠슴

📸 스크린샷

ScreenRecording_04-03-2026.03-19-58_1-1.mov

📟 관련 이슈

Summary by CodeRabbit

New Features

  • 사진 메모 기능 추가: 각 사진에 메모를 작성하고 편집할 수 있습니다.
  • 사진 복제 및 이동: 선택한 사진을 다른 앨범으로 복제하거나 이동할 수 있습니다.
  • 앨범에서 사진 가져오기: 기존 앨범의 사진을 선택하여 가져올 수 있습니다.
  • 인스타그램 스토리 공유 지원: 사진을 인스타그램 스토리로 바로 공유할 수 있습니다.

UI/UX Improvements

  • 사진 이미지 표시 개선: 업로드된 사진이 정확한 종횡비로 표시됩니다.
  • 사진 상세 보기 기능 강화: 확대/축소 및 메모 관리 기능이 추가되었습니다.

@OneTen19 OneTen19 added this to the 4차 스프린트 milestone Apr 2, 2026
@OneTen19 OneTen19 requested a review from Remaked-Swain April 2, 2026 18:24
@OneTen19 OneTen19 self-assigned this Apr 2, 2026
@OneTen19 OneTen19 added Feat 💻 기능 구현 한열 🧄 한열 작업 labels Apr 2, 2026
@OneTen19 OneTen19 linked an issue Apr 2, 2026 that may be closed by this pull request
3 tasks
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

Walkthrough

사진 관리 및 업로드 플로우를 크게 개선하여 이미지 메타데이터(너비, 높이, 크기)를 추적하고, ID 기반에서 엔티티 기반 업로드로 전환했으며, 앨범 선택, 사진 중복/이동, 메모 편집, 인스타그램 공유 기능을 추가했습니다.

Changes

Cohort / File(s) Summary
이미지 업로드 메타데이터
Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadEntity.swift, Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift, Neki-iOS/Core/Sources/ImagePicker/Presentation/Extension/Data+.swift, Neki-iOS/Core/Sources/ImagePicker/Data/DTO/PresignedURLRequestDTO.swift, Neki-iOS/Core/Sources/ImagePicker/Data/DefaultImageUploadRepository.swift
이미지 차원(너비, 높이), 파일 크기 메타데이터를 추가하고 Data 확장으로 이미지 치수 추출 기능 구현
이미지 피커 기능 확장
Neki-iOS/Core/Sources/ImagePicker/Presentation/ImagePickerFeature.swift, Neki-iOS/APP/Sources/MainTab/MainTabCoordinator.swift
autoUpload 속성 추가, 로컬 변환 헬퍼 구현, 위임 패턴 변경으로 비업로드 완료 신호 지원
아카이브 저장소 및 클라이언트
Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/ArchiveRepository.swift, Neki-iOS/Features/Archive/Sources/Domain/Sources/Entities/PhotoEntity.swift, Neki-iOS/Features/Archive/Sources/Data/Sources/DefaultArchiveRepository.swift, Neki-iOS/Features/Archive/Sources/Domain/Sources/Client/ArchiveClient.swift
PhotoUploadMethod 열거형 도입, 메모 업데이트 및 캐시 초기화 작업 추가, 엔티티 메타데이터(메모, 너비, 높이) 필드 추가
아카이브 DTO 및 엔드포인트
Neki-iOS/Features/Archive/Sources/Data/Sources/DTO/PhotoListDTO.swift, Neki-iOS/Features/Archive/Sources/Data/Sources/DTO/Request/UpdateMemoRequestDTO.swift, Neki-iOS/Features/Archive/Sources/Data/Sources/ArchiveEndpoint.swift
메모, 너비, 높이 필드 디코딩 추가, 메모 업데이트 엔드포인트 신규 추가
앨범 선택 기능
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift
선호 앨범 및 앨범 목록 조회, 선택 상태 관리, 새 앨범 생성 기능을 포함하는 새로운 TCA 리듀서 및 뷰 추가
사진 가져오기 기능
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift
앨범별 사진 조회, 다중 선택, 페이지네이션 기능을 갖춘 새로운 TCA 리듀서 및 3열 그리드 뷰 추가
아카이브 메인 기능
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swift
업로드 플로우를 ID 기반에서 엔티티 기반으로 전환, 중복/이동 작업 추가, autoUpload: false 설정, 메타데이터 매핑 확장
사진 상세보기 기능
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift
메모 편집/표시/확장, 앨범 추가, 인스타그램 스토리 공유, 드롭다운 메뉴 상태 관리 추가
앨범 업로드 선택
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/SelectUploadAlbumFeature.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/SelectUploadAlbumView.swift
ID 기반 업로드에서 엔티티 기반 업로드로 전환, 업로드 실행 및 응답 처리 로직 개선
아카이브 좌표자 및 뷰
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Coordinator/ArchiveCoordinator.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveView.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift
앨범 탐색 로직 단순화, 애니메이션 추가, 앨범/사진 가져오기 시트 통합, 앨범 삭제 확인 시트 추가
아카이브 아이템 및 컴포넌트
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Item/ArchiveImageItem.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageCard.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift, Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swift
메모, 너비, 높이 필드 추가, 동적 종횡비 계산, 선택/상세 모드 이원화 UI, 새로운 줌 가능 이미지 뷰 추가
확장 기능
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Extension/Date+.swift
toDotFormatString() 메서드로 날짜를 yyyy.MM.dd 형식으로 변환
디자인 시스템 및 리소스
Neki-iOS/Shared/DesignSystem/Sources/Component/ToolBar/NekiToolBar.swift, Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/.../Contents.json, Neki-iOS/Info.plist
도구 모음 버튼 크기 조정(44x44), 중복/이동/메모 아이콘 추가, 다운로드 아이콘 상태 변경, instagram-stories URL 스킴 추가

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ImagePicker
    participant ImagePickerFeature
    participant ImageUploadClient
    participant ArchiveRepository
    participant Server

    User->>ImagePicker: 사진 선택
    ImagePicker->>ImagePickerFeature: pickerItemsChanged(items)
    ImagePickerFeature->>ImagePickerFeature: convert(items) 로컬 변환
    Note over ImagePickerFeature: 메타데이터 추출<br/>(너비, 높이, 크기)
    ImagePickerFeature->>ImagePickerFeature: autoUpload 확인
    alt autoUpload = true
        ImagePickerFeature->>ImageUploadClient: upload(entities, mediaType)
        ImageUploadClient->>ArchiveRepository: 메타데이터 포함 업로드
        ArchiveRepository->>Server: PresignedURL 요청
        Server-->>ArchiveRepository: URL 응답
        ArchiveRepository->>Server: S3 병렬 업로드
        Server-->>ArchiveRepository: mediaIDs 반환
        ArchiveRepository-->>ImageUploadClient: mediaIDs
        ImageUploadClient-->>ImagePickerFeature: 완료
        ImagePickerFeature->>ImagePickerFeature: requestUpload
    else autoUpload = false
        ImagePickerFeature->>ImagePickerFeature: delegate(.imagesConverted(entities))
        ImagePickerFeature-->>User: 엔티티 위임 (부모에서 처리)
    end
Loading
sequenceDiagram
    participant User
    participant ArchiveAllPhotosView
    participant ArchiveAllPhotosFeature
    participant AlbumSelectionFeature
    participant ArchiveRepository
    participant Server

    User->>ArchiveAllPhotosView: 중복/이동 버튼 클릭
    ArchiveAllPhotosView->>ArchiveAllPhotosFeature: onTapDuplicateButton/onTapMoveButton
    ArchiveAllPhotosFeature->>AlbumSelectionFeature: 앨범 선택 시트 표시
    AlbumSelectionFeature->>ArchiveRepository: fetchAlbums()
    ArchiveRepository->>Server: 앨범 목록 조회
    Server-->>ArchiveRepository: 앨범 데이터
    ArchiveRepository-->>AlbumSelectionFeature: 앨범 리스트
    User->>AlbumSelectionFeature: 대상 앨범 선택
    AlbumSelectionFeature-->>ArchiveAllPhotosFeature: didSelectAlbum(albumId)
    ArchiveAllPhotosFeature->>ArchiveRepository: 중복/이동 요청
    ArchiveRepository->>Server: 사진 중복/이동 API 호출
    Server-->>ArchiveRepository: 성공 응답
    ArchiveRepository-->>ArchiveAllPhotosFeature: 완료
    ArchiveAllPhotosFeature->>ArchiveAllPhotosFeature: 선택 초기화, 토스트 표시
    ArchiveAllPhotosFeature-->>User: 결과 표시
Loading
sequenceDiagram
    participant User
    participant ArchivePhotoDetailView
    participant ArchivePhotoDetailFeature
    participant ArchiveRepository
    participant Server

    User->>ArchivePhotoDetailView: 메모 편집 버튼 클릭
    ArchivePhotoDetailView->>ArchivePhotoDetailFeature: startMemoEditing
    ArchivePhotoDetailFeature->>ArchivePhotoDetailFeature: 편집 UI 활성화
    User->>ArchivePhotoDetailView: 메모 입력 및 완료
    ArchivePhotoDetailView->>ArchivePhotoDetailFeature: doneMemoEditing
    ArchivePhotoDetailFeature->>ArchiveRepository: updatePhotoMemo(photoID, memo)
    ArchiveRepository->>Server: PUT /photos/{photoID} (메모 업데이트)
    Server-->>ArchiveRepository: 성공 응답
    ArchiveRepository->>ArchiveRepository: 로컬 캐시 업데이트
    ArchiveRepository-->>ArchivePhotoDetailFeature: 완료
    ArchivePhotoDetailFeature->>ArchivePhotoDetailFeature: 편집 상태 초기화
    ArchivePhotoDetailFeature-->>User: 메모 표시
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% 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
Title check ✅ Passed PR 제목은 'Sprint4 작업항목 플로우 구현, 인스타그램 스토리 공유'로 변경 사항의 주요 내용을 명확히 요약하고 있습니다.
Description check ✅ Passed PR 설명이 템플릿을 충분히 따르고 작업 내용, PR 포인트, 관련 이슈를 포함하고 있습니다.
Linked Issues check ✅ Passed 앨범 선택 플로우, 사진 가져오기, 앨범 삭제, 인스타그램 스토리 공유 등 #202의 요구사항을 모두 구현했습니다.
Out of Scope Changes check ✅ Passed 모든 변경 사항이 #202의 요구사항과 직접 관련되어 있으며 범위 내 작업입니다.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#202-sprint4

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: 18

Caution

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

⚠️ Outside diff range comments (1)
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift (1)

233-240: ⚠️ Potential issue | 🟠 Major

다운로드 예외가 나면 로딩이 남습니다.

Line 238이 throw하면 downloadImageResponse가 오지 않아 isLoading이 true에 남습니다. 실패 경로에서도 응답을 보내도록 감싸 주세요.

🔧 제안 수정안
 return .run { send in
-    let count = try await imageDownloadClient.downloadImages(urls: [url])
-    await send(.downloadImageResponse(successCount: count))
+    do {
+        let count = try await imageDownloadClient.downloadImages(urls: [url])
+        await send(.downloadImageResponse(successCount: count))
+    } catch {
+        await send(.downloadImageResponse(successCount: 0))
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift`
around lines 233 - 240, 현재 .onTapDownload 처리에서
imageDownloadClient.downloadImages가 throw하면 downloadImageResponse가 전송되지 않아
state.isLoading가 true로 남습니다; .onTapDownload 케이스 내부의 .run 블록에서 downloadImages 호출을
do-catch로 감싸고 성공 시 await send(.downloadImageResponse(successCount: count))를, 실패
시에도 await send(.downloadImageResponse(successCount: 0)) 또는 실패를 표현하는 별도 액션을 전송하도록
수정하세요; 관련 식별자: .onTapDownload, imageDownloadClient.downloadImages(urls:),
downloadImageResponse(successCount:), state.isLoading.
🧹 Nitpick comments (12)
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swift (1)

24-29: 이미지 로드 실패 상태 UI를 분기하는 것을 권장합니다.

현재는 로딩 placeholder는 있지만, 재시도 실패 후 사용자에게 실패 상태를 명확히 보여주지 못할 수 있습니다. 실패 오버레이(재시도 버튼 포함) 추가를 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swift`
around lines 24 - 29, The current AsyncImage usage in ZoomableImageView
(placeholder + .retry + .cancelOnDisappear) shows loading but has no explicit UI
for the failure state; implement a failure overlay with a retry button. Replace
the chained modifiers on AsyncImage with the AsyncImage initializer that
provides an AsyncImagePhase closure (or switch on phase) inside
ZoomableImageView to render: a ProgressView for .empty, the image view for
.success, and a failure overlay (message + Retry button) for .failure; wire the
Retry button to a small `@State` reload token (e.g., reloadToken or reloadCount)
used to re-create/reload the AsyncImage (or call the same image-loading
function) so pressing Retry re-attempts load. Ensure the overlay is visually
distinct and respects cancelOnDisappear behavior.
Neki-iOS/Shared/DesignSystem/Sources/Component/ToolBar/NekiToolBar.swift (1)

100-112: 툴바 아이템 간 크기 불일치 확인 필요.

Icon 버튼의 프레임이 44x44로 변경되었으나, 같은 파일 내 Back(52x52, Line 52)과 Close(52x52, Line 61) 버튼은 여전히 52x52입니다. 이 불일치가 의도된 디자인인지 확인해 주세요.

44pt는 Apple HIG 최소 터치 타겟 요구사항을 충족하지만, 동일 툴바 내에서 일관성을 유지하는 것이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/Shared/DesignSystem/Sources/Component/ToolBar/NekiToolBar.swift`
around lines 100 - 112, Icon button uses a 44x44 frame while Back and Close use
52x52, causing inconsistent toolbar item sizes; update the Icon struct (Icon:
View) to use the same frame as Back and Close (52x52) and ensure its
.contentShape(Rectangle()) remains applied, or if the design intends a smaller
hit area, instead change Back and Close to 44x44 so all toolbar items (Icon,
Back, Close) share the same frame and touch target; adjust any layout constants
or tokens used by NekiToolBar to keep sizes consistent across these components.
Neki-iOS/Core/Sources/ImagePicker/Data/DTO/PresignedURLRequestDTO.swift (1)

18-18: 한 줄에 여러 프로퍼티 선언은 가독성을 낮춥니다.

width, height, size를 한 줄에 선언하는 것보다 각각 별도 줄에 선언하면 가독성과 diff 추적이 개선됩니다.

♻️ 개선된 포맷팅 제안
 public struct PresignedURLRequestData: Encodable {
     public let filename: String
     public let contentType: String
     public let mediaType: String
-    public let width, height, size: Int?
+    public let width: Int?
+    public let height: Int?
+    public let size: Int?
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/Core/Sources/ImagePicker/Data/DTO/PresignedURLRequestDTO.swift` at
line 18, The single-line declaration "public let width, height, size: Int?" in
PresignedURLRequestDTO reduces readability and makes diffs noisy; update the DTO
so each property is declared on its own line (e.g., public let width: Int?;
public let height: Int?; public let size: Int?) within the
PresignedURLRequestDTO type to improve formatting and diff traceability while
keeping the same access level and optional Int type for each property.
Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/ArchiveRepository.swift (2)

33-36: PhotoUploadMethod enum 위치 검토 필요

PhotoUploadMethodpublic으로 선언되어 다른 모듈에서도 사용될 것으로 보입니다. Repository 프로토콜 파일 내에 정의하는 것보다 별도의 Enums 또는 Models 파일로 분리하면 코드 구조가 더 명확해집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/ArchiveRepository.swift`
around lines 33 - 36, PhotoUploadMethod가 public이라 다른 모듈에서도 사용될 가능성이 높으니
ArchiveRepository.swift에 두지 말고 별도의 파일(예: Enums 또는 Models 폴더)로 분리하세요: 새 파일에 enum
PhotoUploadMethod: String, Sendable { case qr = "QR"; case direct =
"DIRECT_UPLOAD" } 형태로 옮기고 기존 ArchiveRepository 및 다른 참조 지점에서 해당 타입을 import 또는 모듈
경로로 참조하도록 수정하며 접근 제어자(public)와 프로퍼티(rawValue, Sendable) 선언은 그대로 유지하세요.

13-13: 튜플 파라미터를 구조체로 리팩토링 고려

uploads: [(mediaID: Int, memo: String?, uploadMethod: PhotoUploadMethod)] 튜플은 필드가 많아 가독성이 떨어질 수 있습니다. 타입 안전성과 명확성을 위해 전용 구조체로 추출하는 것을 고려해보세요.

💡 구조체 추출 제안
public struct PhotoUploadItem: Sendable {
    public let mediaID: Int
    public let memo: String?
    public let uploadMethod: PhotoUploadMethod
    
    public init(mediaID: Int, memo: String?, uploadMethod: PhotoUploadMethod) {
        self.mediaID = mediaID
        self.memo = memo
        self.uploadMethod = uploadMethod
    }
}

// 사용:
func registerPhoto(folderID: Int?, uploads: [PhotoUploadItem], favorite: Bool?) async throws
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/ArchiveRepository.swift`
at line 13, The current method signature
registerPhoto(folderID:uploads:favorite:) uses a tuple type for uploads which
reduces readability and type-safety; replace the tuple with a dedicated value
type by introducing a PhotoUploadItem struct (containing mediaID: Int, memo:
String?, uploadMethod: PhotoUploadMethod) and change the method signature to
accept uploads: [PhotoUploadItem]; update all callers and any conforming
repository implementations to construct and pass PhotoUploadItem instances and
adjust imports/usages of PhotoUploadMethod as needed.
Neki-iOS/Features/Archive/Sources/Domain/Sources/Entities/PhotoEntity.swift (1)

14-14: isfavorite 네이밍이 Swift 컨벤션과 다릅니다.

기존 코드이지만, Swift 네이밍 컨벤션에 따르면 isFavorite(camelCase)가 더 적절합니다. 향후 리팩토링 시 고려해주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/Features/Archive/Sources/Domain/Sources/Entities/PhotoEntity.swift`
at line 14, Rename the property isfavorite in the PhotoEntity struct to follow
Swift camelCase as isFavorite; update all references/usages (initializers,
tests, view models, and any code accessing PhotoEntity.isfavorite) to use
isFavorite, and if the type conforms to Codable or maps to external keys ensure
you add or update a CodingKeys enum or mapping logic to preserve the external
key name (e.g., "isfavorite") so serialization/deserialization remains
unchanged.
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift (1)

83-93: nil-coalescing으로 빈 클로저를 제공하면 핸들러 누락을 감지하기 어렵습니다.

onDuplicate ?? {}onMove ?? {}는 핸들러가 제공되지 않았을 때 조용히 아무 동작도 하지 않습니다. 선택 모드에서 이 액션들이 필수라면, 옵셔널 대신 필수 파라미터로 변경하거나 style == .selection일 때 핸들러 존재를 검증하는 것이 좋습니다.

💡 선택 모드용 별도 이니셜라이저 제안
// 선택 모드 전용 이니셜라이저 추가
public static func selection(
    isEnabled: Bool = true,
    onDownload: `@escaping` () -> Void,
    onDelete: `@escaping` () -> Void,
    onDuplicate: `@escaping` () -> Void,
    onMove: `@escaping` () -> Void
) -> ArchiveImageFooter {
    ArchiveImageFooter(
        style: .selection,
        isEnabled: isEnabled,
        onDownload: onDownload,
        onDelete: onDelete,
        onDuplicate: onDuplicate,
        onMove: onMove
    )
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift`
around lines 83 - 93, The footer currently uses nil-coalescing to replace
missing handlers (onDuplicate ?? {} and onMove ?? {}) which hides missing action
wiring; update ArchiveImageFooter so that duplicate and move handlers are
required for selection mode: either make onDuplicate and onMove non-optional
when style == .selection by adding a selection-specific initializer (e.g.,
ArchiveImageFooter.selection(...) that accepts non-optional onDuplicate and
onMove) or validate inside the view initializer that when style == .selection
the optionals onDuplicate/onMove are non-nil and assert/throw/log if missing;
then wire selectionButton to the non-optional handlers (or force-unwrapped after
validation) instead of using {}.
Neki-iOS/Features/Archive/Sources/Presentation/Sources/Coordinator/ArchiveCoordinator.swift (1)

66-85: 앨범 라우팅 분기 로직을 헬퍼로 합치면 유지보수가 쉬워집니다.

동일한 isFavorite 분기가 3군데 반복되어 이후 수정 시 누락 위험이 있습니다.

Also applies to: 77-85, 106-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Coordinator/ArchiveCoordinator.swift`
around lines 66 - 85, The album routing is duplicated across handlers
(.root(.albumTapped), .root(.afterUploadNavigateToAlbumDetail), and the other
case at 106-113); extract the branch into a helper like appendAlbumRoute(for
album: Album, to state: inout ArchiveCoordinator.State) (or a private method on
ArchiveCoordinator) that does the isFavorite check and calls
state.path.append(.favoriteAlbum(ArchiveFavoriteAlbumFeature.State(album:
album))) or
state.path.append(.albumDetail(ArchiveAlbumDetailFeature.State(album: album))).
Replace the duplicated blocks in the handlers (.albumTapped,
.afterUploadNavigateToAlbumDetail, and the third similar case) with a single
call to this helper and keep existing returns (e.g., return .none) as before.
Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift (1)

14-14: SwiftUI @State 프로퍼티는 private으로 선언해야 합니다.

SwiftLint 경고에 따라 @State 프로퍼티는 외부 접근을 방지하기 위해 private으로 선언하는 것이 권장됩니다.

♻️ 수정 제안
-    `@State` var addAlbumSheetPresented: Bool = false
+    `@State` private var addAlbumSheetPresented: Bool = false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift`
at line 14, The `@State` property addAlbumSheetPresented in AlbumSelectionView
should be made private to satisfy SwiftLint and prevent external access; change
the declaration of addAlbumSheetPresented to "private `@State` var
addAlbumSheetPresented: Bool = false" and if other views need to control it,
expose a Binding or initializer parameter instead of making the state public.
Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift (2)

18-18: SwiftUI @State 프로퍼티는 private으로 선언해야 합니다.

SwiftLint 경고에 따라 @State 프로퍼티는 private으로 선언하는 것이 권장됩니다.

♻️ 수정 제안
-    `@State` var deleteEntireAlbumSheetPresented: Bool = false
+    `@State` private var deleteEntireAlbumSheetPresented: Bool = false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift`
at line 18, Make the `@State` property deleteEntireAlbumSheetPresented private to
satisfy SwiftUI/SwiftLint conventions: change its declaration in
ArchiveAlbumDetailView (the property named deleteEntireAlbumSheetPresented) to
"@State private var deleteEntireAlbumSheetPresented: Bool = false". If other
code currently accesses this state directly, update those callsites to use a
Binding (pass $deleteEntireAlbumSheetPresented) or expose a computed
Binding/closure so external code does not read the internal `@State` directly.

222-232: 앨범 삭제 버튼 색상 TODO에 대한 의견

위험한 액션에 빨간색(.primary500 대신 .red 또는 destructive 색상)을 사용하는 것은 일반적인 UX 패턴입니다. 디자인 팀 답변을 기다리는 동안, 현재 구현도 문제없이 동작합니다.

디자인 피드백 후 색상 변경이 필요하면 도움을 드릴 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift`
around lines 222 - 232, The "앨범 삭제" button currently uses
.foregroundStyle(.primary500); change this to the appropriate destructive color
once design confirms (e.g., replace .foregroundStyle(.primary500) with the
destructive color token or .red) in the Button label inside
ArchiveAlbumDetailView's delete action (the Button that sets
deleteEntireAlbumSheetPresented = true and sends .closeDropDownMenu) so the
visual indicates a dangerous action; update only the foreground style/token when
the design team provides the final color.
Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift (1)

64-70: onChange에서 여러 액션을 순차 전송하는 패턴 검토

currentItemID 변경 시 4개의 액션을 순차적으로 전송하고 있습니다. TCA에서는 동작하지만, 관련 상태 변경을 하나의 액션(예: .resetMemoState)으로 통합하면 리듀서에서 상태 변경을 원자적으로 처리할 수 있어 더 명확합니다.

💡 리듀서에서 통합 액션 처리 예시
// View에서
.onChange(of: store.currentItemID) { _, _ in
    store.send(.onPhotoChanged)
}

// Feature에서
case .onPhotoChanged:
    state.showDropDownMenu = false
    state.isMemoVisible = false
    state.isMemoExpanded = false
    state.isMemoEditing = false
    return .none
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift`
around lines 64 - 70, Consolidate the multiple sequential sends in the onChange
handler into a single action so the reducer can atomically reset memo-related
state: add a new action (e.g. .onPhotoChanged or .resetMemoState) to the
feature's Action enum and implement its reducer branch to set showDropDownMenu =
false, isMemoVisible = false, isMemoExpanded = false, and isMemoEditing = false
(plus any other memo flags like toggleMemoExpanded handling). Then update the
view's onChange(of: store.currentItemID) block to call
store.send(.onPhotoChanged) instead of sending .closeDropDownMenu,
.binding(...isMemoVisible...), .toggleMemoExpanded, and
.binding(...isMemoEditing...), and retain the local focus flag update
(isMemoEditingFocused = false) in the view if it remains a view-local state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Neki-iOS/Core/Sources/ImagePicker/Presentation/Extension/Data`+.swift:
- Around line 54-61: The current extraction of image dimensions from properties
(using kCGImagePropertyPixelWidth and kCGImagePropertyPixelHeight into
width/height) may return zero or negative values; update the logic in the
Data+.swift routine that reads these properties so it only returns a tuple when
both width and height are present and greater than zero (e.g., guard let w =
width, let h = height, w > 0, h > 0 else return nil), otherwise return nil so
downstream layout calculations never receive non-positive sizes.

In `@Neki-iOS/Core/Sources/ImagePicker/Presentation/ImagePickerFeature.swift`:
- Around line 150-168: The detectFormat(from:) function currently defaults
unknown formats to .jpeg; change it to only return explicit known formats (e.g.,
.png, .webp, .jpeg) and return a new .unknown/.unsupported case (or throw an
error) for all others so callers know the format is unsupported and can convert
or fail-safe. Update the ImageFileFormat enum to include .unknown or
.unsupported (or make detectFormat throwing), modify detectFormat to return that
value instead of defaulting to .jpeg, and audit callers that use detectFormat to
handle the new unknown/throwing outcome (perform conversion to JPEG or surface
an error before upload).

In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/DefaultArchiveRepository.swift`:
- Around line 259-271: In updatePhotoMemo, do not substitute capturedAt with
Date() on cache-miss; instead retrieve the authoritative capturedAt from the
source or fail: when photoCache/favoritePhotoCache lookup for photoID returns
nil, call the appropriate endpoint (e.g., a photo/detail or
ArchiveEndpoint.getPhoto) to fetch the original createdAt and use that value in
UpdateMemoRequestDTO, or throw an error if that fetch returns not found; update
references to UpdateMemoRequestDTO and ArchiveEndpoint.updateMemo accordingly so
capturedAt reflects the real value rather than a generated timestamp.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageCard.swift`:
- Line 38: 현재 파일의 섹션 주석 형식이 SwiftLint 규칙과 일치하지 않습니다; 해당 주석 토큰을 찾아 "// MARK: -
Init" 형식으로 수정하세요 (문서 상의 기존 "//MARK: - Init"을 찾아 MARK 토큰과 하이픈 사이에 공백을 추가하여 업데이트).

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swift`:
- Around line 41-45: The onEnded handler for the pinch gesture only calls
clampOffset(geo:) when scale <= 1.0, which allows offset to remain out-of-bounds
after a zoom-out; change the onEnded logic in the pinch gesture (the closure
that sets lastScale) to always call clampOffset(geo:) after resetting lastScale
so that offset is re-clamped regardless of the final scale value (refer to the
onEnded closure, lastScale, scale, and clampOffset(geo:) symbols).

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift`:
- Around line 237-257: The effect handling
albumSelection.presented(.delegate(delegateAction)) (specifically the
didSelectAlbum branch in ArchiveAlbumDetailFeature) currently ignores albumId
and state.selectedIDs and only sleeps then sends duplicatePhotosResponse or
movePhotosResponse; update the effect to capture the albumId and selectedIDs and
call the real move/duplicate API (or the existing interactor/service method)
with those parameters instead of Task.sleep, await the API result and send the
appropriate .duplicatePhotosResponse or .movePhotosResponse with
success/failure; ensure
state.albumSelection/state.selectionPurpose/state.isLoading are updated
consistently and propagate any API errors through the response actions so the
reducer can show error toasts.
- Around line 352-362: The current handler for
.photoImport(.presented(.delegate(delegateAction))) returns an immediate success
after a dummy delay and never uses photoIDs, so the UI shows import success
without actual replication; update the case for .didImportPhotos(photoIDs) to
either (A) call the real import API with the photoIDs (e.g., invoke the
project's importPhotos/importAssets API), await its result, then send
.importPhotosResponse(.success/ .failure) and only clear state.photoImport and
state.isLoading based on the actual response, or (B) if the backend is not
ready, disable this branch: do not set state.isLoading nor send a
success—instead set a flag or send an explicit
.importPhotosResponse(.failure(.notImplemented)) / show an unimplemented UI;
modify the return .run block (the async closure that currently uses Task.sleep
and send(.importPhotosResponse(.success(())))) to perform the chosen behavior
and ensure photoIDs is passed to the API call when implemented.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swift`:
- Around line 188-206: The handler for case .didSelectAlbum in
ArchiveAllPhotosFeature.swift currently ignores the selected albumId and always
returns a fake success after a 1s delay (sending
.duplicatePhotosResponse/.movePhotosResponse), which falsely signals completion;
update this by either (A) wiring the actual albumId into your backend call
(e.g., call your archive API client or service with albumId when
state.selectionPurpose == .duplicate or move, await its result, then send the
real .duplicatePhotosResponse/.movePhotosResponse with the API result and ensure
state.isLoading and state.selectionPurpose are restored/cleared correctly), or
(B) if the API is not available yet, replace the fake success path with an
explicit not-implemented/failure path (send a failure case or dispatch a
.showNotImplementedAlert action), and ensure you set state.isLoading = false and
restore state.albumSelection/selectionPurpose so the UI reflects the
unimplemented state instead of a silent success; locate this logic around the
.albumSelection(.presented(.delegate(delegateAction))) branch and the
.didSelectAlbum handling to implement the fix.
- Around line 105-119: The run effect in the .onTapDownloadButton branch doesn't
handle thrown errors from imageDownloadClient.downloadImages, leaving
state.isLoading true; modify the effect started in onTapDownloadButton to catch
errors and always send a completion action (e.g., extend or overload
downloadImagesResponse to include a failure case or send a separate
.downloadImagesFailed) so that downloadImagesResponse (or the failure action) is
always dispatched; ensure handlers for downloadImagesResponse (or
.downloadImagesFailed) set state.isLoading = false, clear selection, and
dispatch the appropriate delegate toast.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swift`:
- Around line 186-204: The handler for
.albumSelection(.presented(.delegate(delegateAction))) currently ignores the
albumId from .didSelectAlbum(albumId) and unconditionally sends success after a
test sleep; change it to validate and use albumId (from .didSelectAlbum) and
avoid sending a fake .duplicatePhotosResponse(.success(())) or
.movePhotosResponse(.success(())) when no API is wired. Instead, either return
.none or send a failure result (e.g., .failure(.notImplemented)) and keep state
changes reversible (don't clear state.selectionPurpose or set isLoading true
before confirming an operation), or add a guard that blocks the branch unless a
real API client is present so the UI shows an appropriate error/toast rather
than silently faking success.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift`:
- Around line 235-243: The current loop silently skips failed Data(contentsOf:)
reads and always sends send(.uploadSharedImagesResponse(.success(entities)))
even when entities.isEmpty; change this so after building entities you check if
entities.isEmpty and send a failure response (e.g.,
send(.uploadSharedImagesResponse(.failure(...))) or appropriate failure action)
so the UI shows a toast instead of doing nothing; also stop hardcoding format:
.jpeg when constructing ImageUploadEntity — infer the image format from the
source (e.g., derive from the file URL extension or inspect the data/mime type)
and set ImageUploadEntity(format: ...) accordingly; ensure you still call
send(.cleanSharedImages(appGroupID: appGroupID)) in both success and failure
paths.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift`:
- Around line 121-130: The code in the .doneMemoEditing branch overwrites the
local memo immediately and ignores server errors by using try?, so failures show
as success in the UI; change this to perform the network call and handle its
response by dispatching a success/failure action instead of swallowing errors.
Specifically, in the case .doneMemoEditing handler (around state.photos[id:
photoID]?.memo and archiveClient.updatePhotoMemo), stop using try?, call
archiveClient.updatePhotoMemo inside the Effect returned and send a success
action that confirms the local update or a failure action that triggers a
rollback (restore previous memo), shows a failure toast, and sets
state.isMemoEditing/state.isMemoExpanded appropriately; add new actions like
.photoMemoUpdateSuccess(photoID, memo) and .photoMemoUpdateFailure(photoID,
previousMemo, error) and handle them in the reducer to perform rollback and show
the toast.
- Around line 177-203: The .didSelectAlbum(albumId) branch currently returns
.none and never closes the albumSelection sheet; update the handler in
ArchivePhotoDetailFeature (case
.albumSelection(.presented(.delegate(delegateAction)))) so that on
.didSelectAlbum you set state.albumSelection = nil to dismiss the sheet and then
either dispatch an effect to perform the add-to-album flow (e.g., return .run {
await send(.addToAlbumResponse(Result { /* call archiveClient.registerPhotos
with albumId/photoId */ })) }) or, if the server API is unavailable, explicitly
disable/lock the confirm action by setting a flag on state (e.g.,
state.albumSelection?.confirmDisabled = true) and still dismiss the sheet;
ensure you reference and update state.albumSelection and send the
.addToAlbumResponse action (or set the confirmDisabled flag) from the
.didSelectAlbum handler.
- Around line 253-258: The delete branch for the ArchivePhotoDetailFeature's
.onTapDelete currently masks failures because it uses try? then always sends
.deletePhotoResponse(.success(())); change the logic in the .onTapDelete handler
to perform a proper await call to archiveClient.deletePhotoList(photoIds: [id])
inside a do-catch (or capture the thrown error), and only send
.deletePhotoResponse(.success(())) if the call succeeds; on error send
.deletePhotoResponse(.failure(error)) (or map to the feature's failure type) so
the failure branch can run and the local list is not updated on server failure.
- Around line 219-229: The optimistic favorite toggle in
ArchivePhotoDetailFeature uses try? when calling archiveClient.toggleFavorite in
the .onTapFavorite effect so any network error is swallowed and
.toggleFavoriteResponse is never sent; change the effect to await the call
without try?, capture success/failure in a do/catch and send a
.toggleFavoriteResponse action (include photo id and result) from the effect so
the reducer can roll back the optimistic update on failure; update the reducer
branch for .toggleFavoriteResponse to accept the response payload and revert
state.photos[id: …].isFavorite when the network call failed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift`:
- Around line 105-148: When switching albums a previous fetch can complete and
overwrite the new album's photos; modify the fetch flow to either cancel
in-flight fetches on album change or tag responses with the requested album id
and ignore stale ones. Concretely: give the fetch effect a cancellation ID (e.g.
"FetchPhotosID") and make the .run effect in the .fetchPhotos case cancellable,
or capture the requested album id inside the effect and include it in the
.fetchPhotosResponse payload; in selectAlbum call cancel(id: FetchPhotosID) (or
dispatch a cancel effect) before sending .fetchPhotos, and in the
.fetchPhotosResponse handler verify the response's requested id matches
state.selectedAlbum?.id before mutating state.photos and toggling
isFetchingPhotos (if using tagged responses, drop mismatches).

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift`:
- Around line 22-23: The loading message shown in AlbumSelectionView when the
condition store.isFetching && store.albums.isEmpty is true is misleading ("앨범에
추가하고 있어요." implies adding), so update the LoadingView call in AlbumSelectionView
to use a fetching-appropriate message such as "앨범을 불러오고 있어요." (i.e., replace the
string passed to LoadingView when evaluating store.isFetching &&
store.albums.isEmpty); ensure the change is applied where LoadingView(message:
...) is invoked so the UI correctly reflects fetching state.

In `@Neki-iOS/Info.plist`:
- Around line 5-6: The CFBundleIdentifier key in Info.plist is set to an empty
string which can override the build-time PRODUCT_BUNDLE_IDENTIFIER and break
signing/distribution; remove the <key>CFBundleIdentifier</key> entry (or replace
the empty <string> with $(PRODUCT_BUNDLE_IDENTIFIER) if an explicit value is
required) so the build settings supply the bundle identifier instead; locate the
CFBundleIdentifier entry in Info.plist and either delete that key/string pair or
set its value to $(PRODUCT_BUNDLE_IDENTIFIER).

---

Outside diff comments:
In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift`:
- Around line 233-240: 현재 .onTapDownload 처리에서
imageDownloadClient.downloadImages가 throw하면 downloadImageResponse가 전송되지 않아
state.isLoading가 true로 남습니다; .onTapDownload 케이스 내부의 .run 블록에서 downloadImages 호출을
do-catch로 감싸고 성공 시 await send(.downloadImageResponse(successCount: count))를, 실패
시에도 await send(.downloadImageResponse(successCount: 0)) 또는 실패를 표현하는 별도 액션을 전송하도록
수정하세요; 관련 식별자: .onTapDownload, imageDownloadClient.downloadImages(urls:),
downloadImageResponse(successCount:), state.isLoading.

---

Nitpick comments:
In `@Neki-iOS/Core/Sources/ImagePicker/Data/DTO/PresignedURLRequestDTO.swift`:
- Line 18: The single-line declaration "public let width, height, size: Int?" in
PresignedURLRequestDTO reduces readability and makes diffs noisy; update the DTO
so each property is declared on its own line (e.g., public let width: Int?;
public let height: Int?; public let size: Int?) within the
PresignedURLRequestDTO type to improve formatting and diff traceability while
keeping the same access level and optional Int type for each property.

In `@Neki-iOS/Features/Archive/Sources/Domain/Sources/Entities/PhotoEntity.swift`:
- Line 14: Rename the property isfavorite in the PhotoEntity struct to follow
Swift camelCase as isFavorite; update all references/usages (initializers,
tests, view models, and any code accessing PhotoEntity.isfavorite) to use
isFavorite, and if the type conforms to Codable or maps to external keys ensure
you add or update a CodingKeys enum or mapping logic to preserve the external
key name (e.g., "isfavorite") so serialization/deserialization remains
unchanged.

In
`@Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/ArchiveRepository.swift`:
- Around line 33-36: PhotoUploadMethod가 public이라 다른 모듈에서도 사용될 가능성이 높으니
ArchiveRepository.swift에 두지 말고 별도의 파일(예: Enums 또는 Models 폴더)로 분리하세요: 새 파일에 enum
PhotoUploadMethod: String, Sendable { case qr = "QR"; case direct =
"DIRECT_UPLOAD" } 형태로 옮기고 기존 ArchiveRepository 및 다른 참조 지점에서 해당 타입을 import 또는 모듈
경로로 참조하도록 수정하며 접근 제어자(public)와 프로퍼티(rawValue, Sendable) 선언은 그대로 유지하세요.
- Line 13: The current method signature
registerPhoto(folderID:uploads:favorite:) uses a tuple type for uploads which
reduces readability and type-safety; replace the tuple with a dedicated value
type by introducing a PhotoUploadItem struct (containing mediaID: Int, memo:
String?, uploadMethod: PhotoUploadMethod) and change the method signature to
accept uploads: [PhotoUploadItem]; update all callers and any conforming
repository implementations to construct and pass PhotoUploadItem instances and
adjust imports/usages of PhotoUploadMethod as needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift`:
- Around line 83-93: The footer currently uses nil-coalescing to replace missing
handlers (onDuplicate ?? {} and onMove ?? {}) which hides missing action wiring;
update ArchiveImageFooter so that duplicate and move handlers are required for
selection mode: either make onDuplicate and onMove non-optional when style ==
.selection by adding a selection-specific initializer (e.g.,
ArchiveImageFooter.selection(...) that accepts non-optional onDuplicate and
onMove) or validate inside the view initializer that when style == .selection
the optionals onDuplicate/onMove are non-nil and assert/throw/log if missing;
then wire selectionButton to the non-optional handlers (or force-unwrapped after
validation) instead of using {}.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swift`:
- Around line 24-29: The current AsyncImage usage in ZoomableImageView
(placeholder + .retry + .cancelOnDisappear) shows loading but has no explicit UI
for the failure state; implement a failure overlay with a retry button. Replace
the chained modifiers on AsyncImage with the AsyncImage initializer that
provides an AsyncImagePhase closure (or switch on phase) inside
ZoomableImageView to render: a ProgressView for .empty, the image view for
.success, and a failure overlay (message + Retry button) for .failure; wire the
Retry button to a small `@State` reload token (e.g., reloadToken or reloadCount)
used to re-create/reload the AsyncImage (or call the same image-loading
function) so pressing Retry re-attempts load. Ensure the overlay is visually
distinct and respects cancelOnDisappear behavior.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Coordinator/ArchiveCoordinator.swift`:
- Around line 66-85: The album routing is duplicated across handlers
(.root(.albumTapped), .root(.afterUploadNavigateToAlbumDetail), and the other
case at 106-113); extract the branch into a helper like appendAlbumRoute(for
album: Album, to state: inout ArchiveCoordinator.State) (or a private method on
ArchiveCoordinator) that does the isFavorite check and calls
state.path.append(.favoriteAlbum(ArchiveFavoriteAlbumFeature.State(album:
album))) or
state.path.append(.albumDetail(ArchiveAlbumDetailFeature.State(album: album))).
Replace the duplicated blocks in the handlers (.albumTapped,
.afterUploadNavigateToAlbumDetail, and the third similar case) with a single
call to this helper and keep existing returns (e.g., return .none) as before.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift`:
- Line 14: The `@State` property addAlbumSheetPresented in AlbumSelectionView
should be made private to satisfy SwiftLint and prevent external access; change
the declaration of addAlbumSheetPresented to "private `@State` var
addAlbumSheetPresented: Bool = false" and if other views need to control it,
expose a Binding or initializer parameter instead of making the state public.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift`:
- Line 18: Make the `@State` property deleteEntireAlbumSheetPresented private to
satisfy SwiftUI/SwiftLint conventions: change its declaration in
ArchiveAlbumDetailView (the property named deleteEntireAlbumSheetPresented) to
"@State private var deleteEntireAlbumSheetPresented: Bool = false". If other
code currently accesses this state directly, update those callsites to use a
Binding (pass $deleteEntireAlbumSheetPresented) or expose a computed
Binding/closure so external code does not read the internal `@State` directly.
- Around line 222-232: The "앨범 삭제" button currently uses
.foregroundStyle(.primary500); change this to the appropriate destructive color
once design confirms (e.g., replace .foregroundStyle(.primary500) with the
destructive color token or .red) in the Button label inside
ArchiveAlbumDetailView's delete action (the Button that sets
deleteEntireAlbumSheetPresented = true and sends .closeDropDownMenu) so the
visual indicates a dangerous action; update only the foreground style/token when
the design team provides the final color.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift`:
- Around line 64-70: Consolidate the multiple sequential sends in the onChange
handler into a single action so the reducer can atomically reset memo-related
state: add a new action (e.g. .onPhotoChanged or .resetMemoState) to the
feature's Action enum and implement its reducer branch to set showDropDownMenu =
false, isMemoVisible = false, isMemoExpanded = false, and isMemoEditing = false
(plus any other memo flags like toggleMemoExpanded handling). Then update the
view's onChange(of: store.currentItemID) block to call
store.send(.onPhotoChanged) instead of sending .closeDropDownMenu,
.binding(...isMemoVisible...), .toggleMemoExpanded, and
.binding(...isMemoEditing...), and retain the local focus flag update
(isMemoEditingFocused = false) in the view if it remains a view-local state.

In `@Neki-iOS/Shared/DesignSystem/Sources/Component/ToolBar/NekiToolBar.swift`:
- Around line 100-112: Icon button uses a 44x44 frame while Back and Close use
52x52, causing inconsistent toolbar item sizes; update the Icon struct (Icon:
View) to use the same frame as Back and Close (52x52) and ensure its
.contentShape(Rectangle()) remains applied, or if the design intends a smaller
hit area, instead change Back and Close to 44x44 so all toolbar items (Icon,
Back, Close) share the same frame and touch target; adjust any layout constants
or tokens used by NekiToolBar to keep sizes consistent across these components.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2af9e1bb-4977-4795-aba7-3a1f405a829d

📥 Commits

Reviewing files that changed from the base of the PR and between dd31170 and b5383b3.

⛔ Files ignored due to path filters (7)
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/download_disabled.svg is excluded by !**/*.svg
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/download_abled.svg is excluded by !**/*.svg
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/icon_duplicate.svg is excluded by !**/*.svg
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/icon_duplicate_fill.svg is excluded by !**/*.svg
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/icon_move.svg is excluded by !**/*.svg
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/icon_move_fill.svg is excluded by !**/*.svg
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_note.imageset/icon_note.svg is excluded by !**/*.svg
📒 Files selected for processing (46)
  • Neki-iOS/APP/Sources/MainTab/MainTabCoordinator.swift
  • Neki-iOS/Core/Sources/ImagePicker/Data/DTO/PresignedURLRequestDTO.swift
  • Neki-iOS/Core/Sources/ImagePicker/Data/DefaultImageUploadRepository.swift
  • Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swift
  • Neki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadEntity.swift
  • Neki-iOS/Core/Sources/ImagePicker/Presentation/Extension/Data+.swift
  • Neki-iOS/Core/Sources/ImagePicker/Presentation/ImagePickerFeature.swift
  • Neki-iOS/Features/Archive/Sources/Data/Sources/ArchiveEndpoint.swift
  • Neki-iOS/Features/Archive/Sources/Data/Sources/DTO/PhotoListDTO.swift
  • Neki-iOS/Features/Archive/Sources/Data/Sources/DTO/Request/UpdateMemoRequestDTO.swift
  • Neki-iOS/Features/Archive/Sources/Data/Sources/DefaultArchiveRepository.swift
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Client/ArchiveClient.swift
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Entities/PhotoEntity.swift
  • Neki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/ArchiveRepository.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageCard.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Coordinator/ArchiveCoordinator.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Extension/Date+.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/SelectUploadAlbumFeature.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/Item/ArchiveImageItem.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift
  • Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/SelectUploadAlbumView.swift
  • Neki-iOS/Info.plist
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/Contents.json
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/Contents.json
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/Contents.json
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/Contents.json
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/Contents.json
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/Contents.json
  • Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_note.imageset/Contents.json
  • Neki-iOS/Shared/DesignSystem/Sources/Component/ToolBar/NekiToolBar.swift

Comment on lines +54 to +61
var width = properties[kCGImagePropertyPixelWidth] as? Int
var height = properties[kCGImagePropertyPixelHeight] as? Int

if let w = width, let h = height {
return (w, h)
}

return nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

이미지 크기 메타데이터의 유효값(양수) 검증이 필요합니다.

현재는 0 또는 음수 크기도 반환될 수 있어 후속 레이아웃 계산에 문제를 만들 수 있습니다.

🔧 제안 수정안
-        var width = properties[kCGImagePropertyPixelWidth] as? Int
-        var height = properties[kCGImagePropertyPixelHeight] as? Int
-        
-        if let w = width, let h = height {
-            return (w, h)
-        }
-        
-        return nil
+        guard
+            let width = properties[kCGImagePropertyPixelWidth] as? Int, width > 0,
+            let height = properties[kCGImagePropertyPixelHeight] as? Int, height > 0
+        else {
+            return nil
+        }
+        return (width, height)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var width = properties[kCGImagePropertyPixelWidth] as? Int
var height = properties[kCGImagePropertyPixelHeight] as? Int
if let w = width, let h = height {
return (w, h)
}
return nil
guard
let width = properties[kCGImagePropertyPixelWidth] as? Int, width > 0,
let height = properties[kCGImagePropertyPixelHeight] as? Int, height > 0
else {
return nil
}
return (width, height)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/Core/Sources/ImagePicker/Presentation/Extension/Data`+.swift around
lines 54 - 61, The current extraction of image dimensions from properties (using
kCGImagePropertyPixelWidth and kCGImagePropertyPixelHeight into width/height)
may return zero or negative values; update the logic in the Data+.swift routine
that reads these properties so it only returns a tuple when both width and
height are present and greater than zero (e.g., guard let w = width, let h =
height, w > 0, h > 0 else return nil), otherwise return nil so downstream layout
calculations never receive non-positive sizes.

Comment on lines +150 to +168
private static func detectFormat(from data: Data) -> ImageFileFormat {
guard data.count > 12 else { return .jpeg }
let header = data.prefix(12)
let firstByte = header[0]

// PNG 확인 (0x89로 시작)
if firstByte == 0x89 {
return .png
}

// WebP 확인
// WebP 파일 구조:
// Offset 0-3: "RIFF" (0x52, 0x49, 0x46, 0x46)
// Offset 8-11: "WEBP" (0x57, 0x45, 0x42, 0x50)
if header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46 && // "RIFF"
header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50 { // "WEBP"
return .webp
}
return .jpeg
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

지원하지 않는 포맷을 .jpeg로 위장하지 마세요.

지금 분기는 PNG/WebP가 아니면 모두 .jpeg로 반환합니다. 원본이 HEIC 같은 포맷으로 들어오면 실제 바이트는 JPEG가 아닌데 메타데이터만 JPEG로 올라가서, 업로드 단계에서 잘못된 Content-Type/확장자 처리로 이어질 수 있습니다. 지원 포맷만 명시적으로 매핑하고, 나머지는 실제 변환 후 .jpeg를 설정하거나 실패로 처리하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/Core/Sources/ImagePicker/Presentation/ImagePickerFeature.swift`
around lines 150 - 168, The detectFormat(from:) function currently defaults
unknown formats to .jpeg; change it to only return explicit known formats (e.g.,
.png, .webp, .jpeg) and return a new .unknown/.unsupported case (or throw an
error) for all others so callers know the format is unsupported and can convert
or fail-safe. Update the ImageFileFormat enum to include .unknown or
.unsupported (or make detectFormat throwing), modify detectFormat to return that
value instead of defaulting to .jpeg, and audit callers that use detectFormat to
handle the new unknown/throwing outcome (perform conversion to JPEG or surface
an error before upload).

Comment on lines +259 to +271
func updatePhotoMemo(photoID: Int, memo: String) async throws {
var capturedAt = ""
if let cachedItem = photoCache.values.flatMap({ $0 }).first(where: { $0.photoID == photoID }) {
capturedAt = cachedItem.createdAt
} else if let cachedItem = favoritePhotoCache.first(where: { $0.photoID == photoID }) {
capturedAt = cachedItem.createdAt
} else {
capturedAt = Date().ISO8601Format()
}

let requestDTO = UpdateMemoRequestDTO(memo: memo, capturedAt: capturedAt)
let endpoint = ArchiveEndpoint.updateMemo(photoID: photoID, request: requestDTO)
let _ = try await networkProvider.request(endpoint: endpoint)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

캐시 미스 시 capturedAt를 현재 시각으로 대체하면 안 됩니다.

메모만 수정하려는 요청인데, 캐시에 없는 사진은 Date().ISO8601Format()이 들어가서 촬영 시각이 임의로 바뀔 수 있습니다. 이 값이 정렬/표시 기준이면 메모 수정만으로 사진 순서나 날짜 표기가 흔들릴 수 있으니, 원본 값을 다시 조회하거나 없으면 실패로 처리하는 편이 맞습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Data/Sources/DefaultArchiveRepository.swift`
around lines 259 - 271, In updatePhotoMemo, do not substitute capturedAt with
Date() on cache-miss; instead retrieve the authoritative capturedAt from the
source or fail: when photoCache/favoritePhotoCache lookup for photoID returns
nil, call the appropriate endpoint (e.g., a photo/detail or
ArchiveEndpoint.getPhoto) to fetch the original createdAt and use that value in
UpdateMemoRequestDTO, or throw an error if that fetch returns not found; update
references to UpdateMemoRequestDTO and ArchiveEndpoint.updateMemo accordingly so
capturedAt reflects the real value rather than a generated timestamp.

return nil
}

//MARK: - Init
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

MARK 주석 포맷을 SwiftLint 규칙에 맞춰주세요.

Line 38은 // MARK: - Init 형태로 수정하는 것이 필요합니다.

🧰 Tools
🪛 SwiftLint (0.63.2)

[Warning] 38-38: MARK comment should be in valid format. e.g. '// MARK: ...' or '// MARK: - ...'

(mark)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageCard.swift`
at line 38, 현재 파일의 섹션 주석 형식이 SwiftLint 규칙과 일치하지 않습니다; 해당 주석 토큰을 찾아 "// MARK: -
Init" 형식으로 수정하세요 (문서 상의 기존 "//MARK: - Init"을 찾아 MARK 토큰과 하이픈 사이에 공백을 추가하여 업데이트).

Comment on lines +41 to +45
.onEnded { _ in
lastScale = 1.0
if scale <= 1.0 {
clampOffset(geo: geo)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

축소 종료 시 오프셋 경계 재보정이 누락되어 있습니다.

Line 43 조건 때문에 scale > 1.0 상태에서 축소 종료하면, 기존 offset이 새 배율 기준 허용 범위를 벗어난 채 유지될 수 있습니다. 종료 시점에는 항상 clampOffset(geo:)를 호출하는 편이 안정적입니다.

제안 수정안
                 .gesture(
                     MagnifyGesture()
                         .onChanged { value in
                             let delta = value.magnification / lastScale
                             lastScale = value.magnification
                             scale = min(max(scale * delta, 0.8), 5.0) // 0.8 ~ 5배까지
                         }
                         .onEnded { _ in
                             lastScale = 1.0
-                            if scale <= 1.0 {
-                                clampOffset(geo: geo)
-                            }
+                            clampOffset(geo: geo)
                         }
                 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.onEnded { _ in
lastScale = 1.0
if scale <= 1.0 {
clampOffset(geo: geo)
}
.onEnded { _ in
lastScale = 1.0
clampOffset(geo: geo)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swift`
around lines 41 - 45, The onEnded handler for the pinch gesture only calls
clampOffset(geo:) when scale <= 1.0, which allows offset to remain out-of-bounds
after a zoom-out; change the onEnded logic in the pinch gesture (the closure
that sets lastScale) to always call clampOffset(geo:) after resetting lastScale
so that offset is re-clamped regardless of the final scale value (refer to the
onEnded closure, lastScale, scale, and clampOffset(geo:) symbols).

Comment on lines 219 to 229
case .onTapFavorite:
guard let item = state.currentItem else { return .none }
let newStatus = !item.isFavorite

state.photos[id: item.id]?.isFavorite = newStatus

return .run { [id = item.id, isFavorite = newStatus] send in
do {
try await archiveClient.toggleFavorite(photoID: id, request: isFavorite)
await send(.toggleFavoriteResponse(photoID: id, result: .success(())))
} catch {
await send(.toggleFavoriteResponse(photoID: id, result: .failure(error)))
}
try? await archiveClient.toggleFavorite(photoID: id, request: isFavorite)
}

case .toggleFavoriteResponse(_, .success):
case .toggleFavoriteResponse:
return .none
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

즐겨찾기 토글 실패를 복구할 수 없게 됐습니다.

Line 225의 try? 때문에 실패가 사라지고, Line 228의 toggleFavoriteResponse 분기는 더 이상 도달하지 않습니다. 네트워크 실패 시 optimistic update가 그대로 남아 상세 화면 상태가 실제 서버 값과 어긋납니다.

🔧 제안 수정안
 return .run { [id = item.id, isFavorite = newStatus] send in
-    try? await archiveClient.toggleFavorite(photoID: id, request: isFavorite)
+    do {
+        try await archiveClient.toggleFavorite(photoID: id, request: isFavorite)
+        await send(.toggleFavoriteResponse(photoID: id, result: .success(())))
+    } catch {
+        await send(.toggleFavoriteResponse(photoID: id, result: .failure(error)))
+    }
 }
 
-case .toggleFavoriteResponse:
+case let .toggleFavoriteResponse(photoID, .failure):
+    state.photos[id: photoID]?.isFavorite.toggle()
+    return .send(.delegate(.showToast(NekiToastItem("즐겨찾기 변경에 실패했어요", style: .error))))
+
+case .toggleFavoriteResponse:
     return .none
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift`
around lines 219 - 229, The optimistic favorite toggle in
ArchivePhotoDetailFeature uses try? when calling archiveClient.toggleFavorite in
the .onTapFavorite effect so any network error is swallowed and
.toggleFavoriteResponse is never sent; change the effect to await the call
without try?, capture success/failure in a do/catch and send a
.toggleFavoriteResponse action (include photo id and result) from the effect so
the reducer can roll back the optimistic update on failure; update the reducer
branch for .toggleFavoriteResponse to accept the response payload and revert
state.photos[id: …].isFavorite when the network call failed.

Comment on lines 253 to 258
case .onTapDelete:
guard let id = state.currentItem?.id else { return .none }
return .run { send in
do {
try await archiveClient.deletePhotoList(photoIds: [id])
await send(.deletePhotoResponse(.success(())))
} catch {
await send(.deletePhotoResponse(.failure(error)))
}
try? await archiveClient.deletePhotoList(photoIds: [id])
await send(.deletePhotoResponse(.success(())))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

삭제 실패도 성공으로 처리되고 있습니다.

Line 256의 try? 뒤에서 Line 257이 항상 success를 보내서, 서버 삭제가 실패해도 로컬 리스트에서는 사진이 사라집니다. 현재 .deletePhotoResponse(.failure) 분기는 사실상 도달하지 못합니다.

🔧 제안 수정안
 return .run { send in
-    try? await archiveClient.deletePhotoList(photoIds: [id])
-    await send(.deletePhotoResponse(.success(())))
+    do {
+        try await archiveClient.deletePhotoList(photoIds: [id])
+        await send(.deletePhotoResponse(.success(())))
+    } catch {
+        await send(.deletePhotoResponse(.failure(error)))
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case .onTapDelete:
guard let id = state.currentItem?.id else { return .none }
return .run { send in
do {
try await archiveClient.deletePhotoList(photoIds: [id])
await send(.deletePhotoResponse(.success(())))
} catch {
await send(.deletePhotoResponse(.failure(error)))
}
try? await archiveClient.deletePhotoList(photoIds: [id])
await send(.deletePhotoResponse(.success(())))
}
case .onTapDelete:
guard let id = state.currentItem?.id else { return .none }
return .run { send in
do {
try await archiveClient.deletePhotoList(photoIds: [id])
await send(.deletePhotoResponse(.success(())))
} catch {
await send(.deletePhotoResponse(.failure(error)))
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift`
around lines 253 - 258, The delete branch for the ArchivePhotoDetailFeature's
.onTapDelete currently masks failures because it uses try? then always sends
.deletePhotoResponse(.success(())); change the logic in the .onTapDelete handler
to perform a proper await call to archiveClient.deletePhotoList(photoIds: [id])
inside a do-catch (or capture the thrown error), and only send
.deletePhotoResponse(.success(())) if the call succeeds; on error send
.deletePhotoResponse(.failure(error)) (or map to the feature's failure type) so
the failure branch can run and the local list is not updated on server failure.

Comment on lines +105 to +148
case .fetchPhotos:
guard !state.isFetchingPhotos else { return .none }
state.isFetchingPhotos = true
let targetFolderId = state.selectedAlbum?.id == -1 ? nil : state.selectedAlbum?.id
let sortOrder = "DESC"

return .run { [id = state.selectedAlbum?.id] send in
if id == -1 {
await send(.fetchPhotosResponse(Result { try await archiveClient.fetchFavoritePhotoList(20, sortOrder) }))
} else {
await send(.fetchPhotosResponse(Result { try await archiveClient.fetchPhotoList(folderId: targetFolderId, size: 20, sortOrder: sortOrder) }))
}
}

case .loadMorePhotos:
return .send(.fetchPhotos)

case let .fetchPhotosResponse(.success(entities)):
state.isFetchingPhotos = false
let currentAlbumId = state.selectedAlbum?.id
let newItems = entities.map { entity in
ArchiveImageItem(id: entity.photoID, imageURLString: entity.imageURL, isFavorite: entity.isfavorite, date: entity.createdAt.toISO8601Date(), folderId: currentAlbumId, memo: entity.memo ?? "", width: entity.width, height: entity.height)
}
state.photos = IdentifiedArray(uniqueElements: newItems)
return .none

case .fetchPhotosResponse(.failure):
state.isFetchingPhotos = false
return .none

case .toggleDropdown:
state.isDropdownOpen.toggle()
return .none

case .closeDropdown:
state.isDropdownOpen = false
return .none

case let .selectAlbum(album):
state.selectedIDs.removeAll() // TODO: - 현재는 앨범 변경 시 선택된 사진 해제. 피그마에 문의 남김. 추후 수정될 여지 있음
state.selectedAlbum = album
state.isDropdownOpen = false
state.photos.removeAll()
return .send(.fetchPhotos)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

앨범 전환 중에는 이전 fetch 응답을 버릴 수 있어야 합니다.

지금은 selectAlbum 직후에도 기존 요청이 살아 있으면 새 .fetchPhotosisFetchingPhotos guard에 막히고, 늦게 도착한 이전 응답이 그대로 state.photos를 덮습니다. 그래서 다른 앨범을 눌렀는데 직전 앨범 사진이 다시 보이는 race가 생깁니다. 응답에 요청한 album ID/token을 싣고 현재 선택과 일치할 때만 반영하거나, 전환 시 이전 effect를 cancel 해야 합니다.

패치 방향 예시
 enum Action: BindableAction {
@@
-    case fetchPhotosResponse(Result<[PhotoEntity], Error>)
+    case fetchPhotosResponse(requestedAlbumID: Int?, Result<[PhotoEntity], Error>)
@@
             case .fetchPhotos:
                 guard !state.isFetchingPhotos else { return .none }
                 state.isFetchingPhotos = true
                 let targetFolderId = state.selectedAlbum?.id == -1 ? nil : state.selectedAlbum?.id
                 let sortOrder = "DESC"
                 
-                return .run { [id = state.selectedAlbum?.id] send in
-                    if id == -1 {
-                        await send(.fetchPhotosResponse(Result { try await archiveClient.fetchFavoritePhotoList(20, sortOrder) }))
+                return .run { [requestedAlbumID = state.selectedAlbum?.id] send in
+                    if requestedAlbumID == -1 {
+                        await send(.fetchPhotosResponse(requestedAlbumID: requestedAlbumID, Result { try await archiveClient.fetchFavoritePhotoList(20, sortOrder) }))
                     } else {
-                        await send(.fetchPhotosResponse(Result { try await archiveClient.fetchPhotoList(folderId: targetFolderId, size: 20, sortOrder: sortOrder) }))
+                        await send(.fetchPhotosResponse(requestedAlbumID: requestedAlbumID, Result { try await archiveClient.fetchPhotoList(folderId: targetFolderId, size: 20, sortOrder: sortOrder) }))
                     }
                 }
@@
-            case let .fetchPhotosResponse(.success(entities)):
+            case let .fetchPhotosResponse(requestedAlbumID, .success(entities)):
+                guard requestedAlbumID == state.selectedAlbum?.id else { return .none }
                 state.isFetchingPhotos = false
                 let currentAlbumId = state.selectedAlbum?.id
                 let newItems = entities.map { entity in
                     ArchiveImageItem(id: entity.photoID, imageURLString: entity.imageURL, isFavorite: entity.isfavorite, date: entity.createdAt.toISO8601Date(), folderId: currentAlbumId, memo: entity.memo ?? "", width: entity.width, height: entity.height)
                 }
                 state.photos = IdentifiedArray(uniqueElements: newItems)
                 return .none
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift`
around lines 105 - 148, When switching albums a previous fetch can complete and
overwrite the new album's photos; modify the fetch flow to either cancel
in-flight fetches on album change or tag responses with the requested album id
and ignore stale ones. Concretely: give the fetch effect a cancellation ID (e.g.
"FetchPhotosID") and make the .run effect in the .fetchPhotos case cancellable,
or capture the requested album id inside the effect and include it in the
.fetchPhotosResponse payload; in selectAlbum call cancel(id: FetchPhotosID) (or
dispatch a cancel effect) before sending .fetchPhotos, and in the
.fetchPhotosResponse handler verify the response's requested id matches
state.selectedAlbum?.id before mutating state.photos and toggling
isFetchingPhotos (if using tagged responses, drop mismatches).

Comment on lines +22 to +23
if store.isFetching && store.albums.isEmpty {
LoadingView(message: "앨범에 추가하고 있어요.")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

로딩 메시지가 현재 상황과 맞지 않을 수 있습니다.

store.isFetching && store.albums.isEmpty 조건은 앨범 목록을 가져오는 중일 때 표시되지만, 메시지는 "앨범에 추가하고 있어요."로 추가 작업 중을 의미합니다. 앨범 목록 로딩 시에는 "앨범을 불러오고 있어요."와 같은 메시지가 더 적절해 보입니다.

💡 수정 제안
                 if store.isFetching && store.albums.isEmpty {
-                    LoadingView(message: "앨범에 추가하고 있어요.")
+                    LoadingView(message: "앨범을 불러오고 있어요.")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if store.isFetching && store.albums.isEmpty {
LoadingView(message: "앨범에 추가하고 있어요.")
if store.isFetching && store.albums.isEmpty {
LoadingView(message: "앨범을 불러오고 있어요.")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift`
around lines 22 - 23, The loading message shown in AlbumSelectionView when the
condition store.isFetching && store.albums.isEmpty is true is misleading ("앨범에
추가하고 있어요." implies adding), so update the LoadingView call in AlbumSelectionView
to use a fetching-appropriate message such as "앨범을 불러오고 있어요." (i.e., replace the
string passed to LoadingView when evaluating store.isFetching &&
store.albums.isEmpty); ensure the change is applied where LoadingView(message:
...) is invoked so the UI correctly reflects fetching state.

Comment on lines +5 to +6
<key>CFBundleIdentifier</key>
<string></string>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

CFBundleIdentifier 값은 빌드 문제를 일으킬 수 있습니다.

CFBundleIdentifier가 빈 문자열로 설정되어 있습니다. 일반적으로 이 값은 Xcode 빌드 설정의 $(PRODUCT_BUNDLE_IDENTIFIER)에서 자동으로 설정됩니다. 명시적인 빈 값이 빌드 타임 값을 덮어쓰면 앱 서명이나 배포 시 문제가 발생할 수 있습니다.

이 항목이 의도적으로 추가된 것인지 확인하고, 필요 없다면 제거하는 것을 권장합니다.

🔧 제안: 불필요하면 해당 항목 제거
-	<key>CFBundleIdentifier</key>
-	<string></string>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<key>CFBundleIdentifier</key>
<string></string>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Neki-iOS/Info.plist` around lines 5 - 6, The CFBundleIdentifier key in
Info.plist is set to an empty string which can override the build-time
PRODUCT_BUNDLE_IDENTIFIER and break signing/distribution; remove the
<key>CFBundleIdentifier</key> entry (or replace the empty <string> with
$(PRODUCT_BUNDLE_IDENTIFIER) if an explicit value is required) so the build
settings supply the bundle identifier instead; locate the CFBundleIdentifier
entry in Info.plist and either delete that key/string pair or set its value to
$(PRODUCT_BUNDLE_IDENTIFIER).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feat 💻 기능 구현 한열 🧄 한열 작업

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 4차 스프린트 이미지 관리 및 업로드 플로우 고도화

1 participant