[Feat] #202 - Sprint4 작업항목 플로우 구현, 인스타그램 스토리 공유#203
[Feat] #202 - Sprint4 작업항목 플로우 구현, 인스타그램 스토리 공유#203
Conversation
- 사진 이동 || 복제 시 앨범 선택하는 재사용 뷰 리듀서
- 사진 이동 || 복제 시 앨범 선택하는 재사용 뷰
Walkthrough사진 관리 및 업로드 플로우를 크게 개선하여 이미지 메타데이터(너비, 높이, 크기)를 추적하고, ID 기반에서 엔티티 기반 업로드로 전환했으며, 앨범 선택, 사진 중복/이동, 메모 편집, 인스타그램 공유 기능을 추가했습니다. Changes
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
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: 결과 표시
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: 메모 표시
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 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:PhotoUploadMethodenum 위치 검토 필요
PhotoUploadMethod가public으로 선언되어 다른 모듈에서도 사용될 것으로 보입니다. 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
⛔ Files ignored due to path filters (7)
Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/download_disabled.svgis excluded by!**/*.svgNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/download_abled.svgis excluded by!**/*.svgNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/icon_duplicate.svgis excluded by!**/*.svgNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/icon_duplicate_fill.svgis excluded by!**/*.svgNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/icon_move.svgis excluded by!**/*.svgNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/icon_move_fill.svgis excluded by!**/*.svgNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_note.imageset/icon_note.svgis excluded by!**/*.svg
📒 Files selected for processing (46)
Neki-iOS/APP/Sources/MainTab/MainTabCoordinator.swiftNeki-iOS/Core/Sources/ImagePicker/Data/DTO/PresignedURLRequestDTO.swiftNeki-iOS/Core/Sources/ImagePicker/Data/DefaultImageUploadRepository.swiftNeki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadClient.swiftNeki-iOS/Core/Sources/ImagePicker/Domain/ImageUploadEntity.swiftNeki-iOS/Core/Sources/ImagePicker/Presentation/Extension/Data+.swiftNeki-iOS/Core/Sources/ImagePicker/Presentation/ImagePickerFeature.swiftNeki-iOS/Features/Archive/Sources/Data/Sources/ArchiveEndpoint.swiftNeki-iOS/Features/Archive/Sources/Data/Sources/DTO/PhotoListDTO.swiftNeki-iOS/Features/Archive/Sources/Data/Sources/DTO/Request/UpdateMemoRequestDTO.swiftNeki-iOS/Features/Archive/Sources/Data/Sources/DefaultArchiveRepository.swiftNeki-iOS/Features/Archive/Sources/Domain/Sources/Client/ArchiveClient.swiftNeki-iOS/Features/Archive/Sources/Domain/Sources/Entities/PhotoEntity.swiftNeki-iOS/Features/Archive/Sources/Domain/Sources/Interfaces/Repositories/ArchiveRepository.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageCard.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ZoomableImageView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Coordinator/ArchiveCoordinator.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Extension/Date+.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/SelectUploadAlbumFeature.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/Item/ArchiveImageItem.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swiftNeki-iOS/Features/Archive/Sources/Presentation/Sources/View/SelectUploadAlbumView.swiftNeki-iOS/Info.plistNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/Contents.jsonNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/Contents.jsonNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/Contents.jsonNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/Contents.jsonNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/Contents.jsonNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/Contents.jsonNeki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_note.imageset/Contents.jsonNeki-iOS/Shared/DesignSystem/Sources/Component/ToolBar/NekiToolBar.swift
| var width = properties[kCGImagePropertyPixelWidth] as? Int | ||
| var height = properties[kCGImagePropertyPixelHeight] as? Int | ||
|
|
||
| if let w = width, let h = height { | ||
| return (w, h) | ||
| } | ||
|
|
||
| return nil |
There was a problem hiding this comment.
이미지 크기 메타데이터의 유효값(양수) 검증이 필요합니다.
현재는 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.
| 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.
| 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 |
There was a problem hiding this comment.
지원하지 않는 포맷을 .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).
| 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) |
There was a problem hiding this comment.
캐시 미스 시 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 |
There was a problem hiding this comment.
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 토큰과 하이픈 사이에 공백을 추가하여 업데이트).
| .onEnded { _ in | ||
| lastScale = 1.0 | ||
| if scale <= 1.0 { | ||
| clampOffset(geo: geo) | ||
| } |
There was a problem hiding this comment.
축소 종료 시 오프셋 경계 재보정이 누락되어 있습니다.
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.
| .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).
| 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 |
There was a problem hiding this comment.
즐겨찾기 토글 실패를 복구할 수 없게 됐습니다.
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.
| 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(()))) | ||
| } |
There was a problem hiding this comment.
삭제 실패도 성공으로 처리되고 있습니다.
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.
| 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.
| 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) |
There was a problem hiding this comment.
앨범 전환 중에는 이전 fetch 응답을 버릴 수 있어야 합니다.
지금은 selectAlbum 직후에도 기존 요청이 살아 있으면 새 .fetchPhotos가 isFetchingPhotos 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).
| if store.isFetching && store.albums.isEmpty { | ||
| LoadingView(message: "앨범에 추가하고 있어요.") |
There was a problem hiding this comment.
로딩 메시지가 현재 상황과 맞지 않을 수 있습니다.
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.
| 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.
| <key>CFBundleIdentifier</key> | ||
| <string></string> |
There was a problem hiding this comment.
빈 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.
| <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).
🌴 작업한 브랜치
feat/#202-sprint4✅ 작업한 내용
사진 이동/복제 플로우와 사진 가져오기(Import) 기능을 구현하고, 전반적인 사용성을 높이기 위한 UI/UX 개선 작업을 진행했습니다.
❗️PR Point
📸 스크린샷
ScreenRecording_04-03-2026.03-19-58_1-1.mov
📟 관련 이슈
Summary by CodeRabbit
New Features
UI/UX Improvements