From 2081b171181b511bea75e371c270bd98dc0cddf9 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 21:07:52 +0900 Subject: [PATCH 01/17] [Feat] #202 - Data extension --- .../Sources/Extension/Date+.swift | 24 +++++++++++++++++++ .../Feature/AlbumSelectionFeature.swift | 8 +++++++ .../Sources/View/AlbumSelectionView.swift | 8 +++++++ 3 files changed, 40 insertions(+) create mode 100644 Neki-iOS/Features/Archive/Sources/Presentation/Sources/Extension/Date+.swift create mode 100644 Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift create mode 100644 Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Extension/Date+.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Extension/Date+.swift new file mode 100644 index 00000000..58f21b4f --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Extension/Date+.swift @@ -0,0 +1,24 @@ +// +// Date+.swift +// Neki-iOS +// +// Created by OneTen on 4/2/26. +// + +import Foundation + +extension Date { + /// Date를 "yyyy.MM.dd" 형태의 문자열로 변환합니다. + func toDotFormatString() -> String { + return DateFormatters.dotFormat.string(from: self) + } +} + +private struct DateFormatters { + static let dotFormat: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + formatter.locale = Locale(identifier: "ko_KR") + return formatter + }() +} diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift new file mode 100644 index 00000000..59213077 --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift @@ -0,0 +1,8 @@ +// +// AlbumSelectionFeature.swift +// Neki-iOS +// +// Created by OneTen on 4/2/26. +// + +import Foundation diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift new file mode 100644 index 00000000..2249c1e1 --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift @@ -0,0 +1,8 @@ +// +// AlbumSelectionView.swift +// Neki-iOS +// +// Created by OneTen on 4/2/26. +// + +import Foundation From 97c1bd863311b4ec37c104c78fce76d5df5f4fe5 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 22:40:37 +0900 Subject: [PATCH 02/17] =?UTF-8?q?[Feat]=20#202=20-=20AlbumSelectionFeature?= =?UTF-8?q?=20-=20=EC=82=AC=EC=A7=84=20=EC=9D=B4=EB=8F=99=20||=20=EB=B3=B5?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=95=A8=EB=B2=94=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EB=B7=B0=20?= =?UTF-8?q?=EB=A6=AC=EB=93=80=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/AlbumSelectionFeature.swift | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift index 59213077..0ff6abfe 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift @@ -5,4 +5,162 @@ // Created by OneTen on 4/2/26. // +import ComposableArchitecture import Foundation + +@Reducer +struct AlbumSelectionFeature { + @ObservableState + struct State { + var albums: IdentifiedArrayOf = [] + var selectedAlbumId: Int? = nil + var uploadCount: Int + var isFetching: Bool = false + + // 앨범 생성관련 + var newAlbumTitle: String = "" + var albumTitleErrorMessage: String? = nil + + var isConfirmButtonEnabled: Bool { + return !newAlbumTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && albumTitleErrorMessage == nil + } + + init(uploadCount: Int = 1) { + self.uploadCount = uploadCount + } + } + + enum Action: BindableAction { + case binding(BindingAction) + + // 생명주기 및 네트워크 + case onAppear + case fetchFavoriteAlbumResponse(Result) + case fetchAlbumsResponse(Result<[AlbumItem], Error>) + + // 사용자 액션 + case tapBack + case tapAlbum(Int) + case tapConfirm + + // 앨범 생성 액션 + case onTapCancelAddAlbum + case onTapConfirmAddAlbum + case addFolderResponse(Result) + + case delegate(DelegateAction) + enum DelegateAction { + case didSelectAlbum(albumId: Int) + case didTapCancel + case showToast(NekiToastItem) + } + } + + @Dependency(\.archiveClient) var archiveClient + + var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .onAppear: + state.isFetching = true + return .merge( + .run { send in + do { + let entity = try await archiveClient.getFavoriteAlbumInfo() + let favoriteAlbum = AlbumItem(id: -1, title: "즐겨찾기", count: entity.totalCount, coverImageURL: URL(string: entity.latestImageURL), isFavorite: true) + await send(.fetchFavoriteAlbumResponse(.success(favoriteAlbum))) + } catch { + await send(.fetchFavoriteAlbumResponse(.failure(error))) + } + }, + .run { send in + await send(.fetchAlbumsResponse(Result { + let entities = try await archiveClient.getAlbumList() + return entities.map { AlbumItem(id: $0.id, title: $0.name, count: $0.photoCount, coverImageURL: URL(string: $0.coverImageURLString), isFavorite: false) } + })) + } + ) + + case let .fetchFavoriteAlbumResponse(.success(album)): + state.albums.removeAll(where: { $0.isFavorite }) + state.albums.insert(album, at: 0) + return .none + + case .fetchFavoriteAlbumResponse(.failure): + return .none + + case let .fetchAlbumsResponse(.success(fetchedAlbums)): + state.isFetching = false + let favorite = state.albums.first(where: { $0.isFavorite }) + var newAlbums: [AlbumItem] = [] + if let fav = favorite { newAlbums.append(fav) } + newAlbums.append(contentsOf: fetchedAlbums) + state.albums = IdentifiedArray(uniqueElements: newAlbums) + return .none + + case .fetchAlbumsResponse(.failure): + state.isFetching = false + return .none + + case .tapBack: + return .send(.delegate(.didTapCancel)) + + case let .tapAlbum(id): + // 즐겨찾기 앨범은 선택 불가하게 + guard id != -1 else { return .none } + + if state.selectedAlbumId == id { + state.selectedAlbumId = nil + } else { + state.selectedAlbumId = id + } + return .none + + case .tapConfirm: + guard let id = state.selectedAlbumId else { return .none } + return .send(.delegate(.didSelectAlbum(albumId: id))) + + // MARK: - 앨범 생성 관련 처리 + case .onTapCancelAddAlbum: + state.newAlbumTitle = "" + state.albumTitleErrorMessage = nil + return .none + + case .onTapConfirmAddAlbum: + guard state.isConfirmButtonEnabled else { return .none } + let title = state.newAlbumTitle.trimmingCharacters(in: .whitespacesAndNewlines) + state.newAlbumTitle = "" + state.albumTitleErrorMessage = nil + + return .run { send in + await send(.addFolderResponse(Result { + try await archiveClient.addFolder(name: title) + })) + } + + case .addFolderResponse(.success): + return .merge( + .send(.delegate(.showToast(NekiToastItem("새로운 앨범을 추가했어요", style: .success)))), + .send(.onAppear) // 앨범 목록 갱신 + ) + + case .addFolderResponse(.failure): + return .send(.delegate(.showToast(NekiToastItem("앨범을 만들지 못했어요", style: .error)))) + + case .binding(\.newAlbumTitle): + let inputTitle = state.newAlbumTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if state.albums.contains(where: { $0.title == inputTitle }) { + state.albumTitleErrorMessage = "이미 사용 중인 앨범명이에요." + } else { + state.albumTitleErrorMessage = nil + } + return .none + + case .binding, .delegate: + return .none + } + } + } +} From 933f914e5f9903a3034cee80121d85ee3abfd0b1 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 22:41:00 +0900 Subject: [PATCH 03/17] =?UTF-8?q?[Feat]=20#202=20-=20AlbumSelectionView=20?= =?UTF-8?q?-=20=EC=82=AC=EC=A7=84=20=EC=9D=B4=EB=8F=99=20||=20=EB=B3=B5?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=95=A8=EB=B2=94=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EB=B7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/View/AlbumSelectionView.swift | 125 +++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift index 2249c1e1..063a7764 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift @@ -5,4 +5,127 @@ // Created by OneTen on 4/2/26. // -import Foundation +import SwiftUI +import ComposableArchitecture + +struct AlbumSelectionView: View { + @Bindable var store: StoreOf + + @State var addAlbumSheetPresented: Bool = false + + public var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 0) { + // Header + header + + if store.isFetching && store.albums.isEmpty { + LoadingView(message: "앨범에 추가하고 있어요.") + } else { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // 새 앨범 추가 버튼 + Button { + addAlbumSheetPresented = true + } label: { + HStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(.primary400, style: StrokeStyle(lineWidth: 1, lineCap: .round, dash:[5,5], dashPhase: 0)) + .frame(width: 72, height: 72) + .background(Color.white) + + Image(.iconPlusRed) + } + + Text("새 앨범 추가") + .nekiFont(.body16SemiBold) + .foregroundStyle(.gray900) + + Spacer() + } + } + .padding(.horizontal, 20) + + // 앨범 목록 리스트 + ForEach(store.albums) { album in + AlbumRowTile( + album: album, + isSelectMode: true, + isDeleteMode: false, + isSelected: store.selectedAlbumId == album.id + ) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + .onTapGesture { + // 즐겨찾기 앨범은 선택하지 못하도록 예외 처리 + if !album.isFavorite { + store.send(.tapAlbum(album.id)) + } + } + } + } + .padding(.top, 8) + .padding(.bottom, 40) + } + } + } + .navigationBarHidden(true) + .background(Color.white.ignoresSafeArea()) + } + .task { + await store.send(.onAppear).finish() + } + .sheet(isPresented: $addAlbumSheetPresented) { + ArchiveAlbumInputSheet( + style: .add, + text: $store.newAlbumTitle, + errorMessage: store.albumTitleErrorMessage, + isConfirmEnabled: store.isConfirmButtonEnabled, + onCancel: { + store.send(.onTapCancelAddAlbum) + addAlbumSheetPresented = false + }, + onConfirm: { + store.send(.onTapConfirmAddAlbum) + addAlbumSheetPresented = false + } + ) + .presentationDetents([.height(266)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(20) + } + } +} + +extension AlbumSelectionView { + private var header: some View { + ZStack(alignment: .center) { + HStack { + Button { + store.send(.tapBack) + } label: { + Image(.iconChevronLeft) + } + + Spacer() + + Button { + store.send(.tapConfirm) + } label: { + Text("\(store.uploadCount)장 업로드") + .nekiFont(.body16SemiBold) + .foregroundStyle(store.selectedAlbumId == nil ? .gray200 : .primary500) + } + .disabled(store.selectedAlbumId == nil) + } + .frame(height: 54) + .padding(.horizontal, 20) + + Text("앨범에 추가") + .nekiFont(.title20SemiBold) + .foregroundStyle(.gray900) + } + .frame(height: 54) + } +} From 6272f286e2a0b9ec2cfb959be9c5712568b08627 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 22:41:56 +0900 Subject: [PATCH 04/17] =?UTF-8?q?[Feat]=20#202=20-=20PhotoDetail=20?= =?UTF-8?q?=EB=B7=B0=EC=97=90=EC=84=9C=20=EC=95=A8=EB=B2=94=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=B7=B0=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/ArchivePhotoDetailFeature.swift | 162 +++++++++++------- .../Sources/View/ArchivePhotoDetailView.swift | 78 ++++++++- .../Sources/View/SelectUploadAlbumView.swift | 1 - 3 files changed, 169 insertions(+), 72 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift index 61d839d1..d790e759 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift @@ -14,41 +14,31 @@ struct ArchivePhotoDetailFeature { @ObservableState struct State { @Presents var imageTransform: ImageTransformFeature.State? + @Presents var albumSelection: AlbumSelectionFeature.State? var photos: IdentifiedArrayOf - var currentItemID: Int let folderId: Int? - var currentItem: ArchiveImageItem? { - photos[id: currentItemID] - } - + var currentItem: ArchiveImageItem? { photos[id: currentItemID] } var formattedDate: String { - guard let date = currentItem?.date else { return "" } - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd" - return formatter.string(from: date) + return currentItem?.date.toDotFormatString() ?? "" } var isLoading: Bool = false + var isMemoVisible: Bool = false + var isMemoExpanded: Bool = false + var isMemoEditing: Bool = false + var editingMemoText: String = "" - // MARK: - Memo States - - var isMemoVisible: Bool = false // 단순 조회 모드 가시성 - var isMemoExpanded: Bool = false // 단순 조회 모드 더 보기(확장) 여부 - var isMemoEditing: Bool = false // 편집 모드 여부 - var editingMemoText: String = "" // 편집 중인 임시 텍스트 + var showDropDownMenu: Bool = false } enum Action: BindableAction { case binding(BindingAction) - // 메모 조회 모드 액션 case toggleMemoVisibility case toggleMemoExpanded(Bool) - - // 메모 편집 모드 액션 case startMemoEditing case cancelMemoEditing case doneMemoEditing @@ -58,16 +48,21 @@ struct ArchivePhotoDetailFeature { case imageFetchResponse(Result) case imageTransform(PresentationAction) + case onTapAddToAlbum + case albumSelection(PresentationAction) + case addToAlbumResponse(Result) + case onTapBackButton case onTapDownload case downloadImageResponse(successCount: Int) - case onTapFavorite case toggleFavoriteResponse(photoID: Int, result: Result) - case onTapDelete case deletePhotoResponse(Result) + case toggleDropDownMenu + case closeDropDownMenu + case delegate(Delegate) enum Delegate { case showToast(NekiToastItem) @@ -84,7 +79,19 @@ struct ArchivePhotoDetailFeature { Reduce { state, action in switch action { - // MARK: - 메모 조회 모드 로직 + case .onTapBackButton: + return .run { _ in await dismiss() } + + // MARK: - DropDown Menu + case .toggleDropDownMenu: + state.showDropDownMenu.toggle() + return .none + + case .closeDropDownMenu: + state.showDropDownMenu = false + return .none + + // MARK: - Memo case .toggleMemoVisibility: state.isMemoVisible.toggle() if !state.isMemoVisible { @@ -96,7 +103,6 @@ struct ArchivePhotoDetailFeature { state.isMemoExpanded = isExpanded return .none - // MARK: - 메모 편집 모드 로직 case .startMemoEditing: state.isMemoEditing = true state.isMemoVisible = true @@ -110,38 +116,38 @@ struct ArchivePhotoDetailFeature { return .none case .doneMemoEditing: - // 100자 자르기 let limitedText = String(state.editingMemoText.prefix(100)) let photoID = state.currentItemID - state.photos[id: photoID]?.memo = limitedText state.isMemoEditing = false state.isMemoExpanded = false return .run { send in - do { - try await archiveClient.updatePhotoMemo(photoID: photoID, memo: limitedText) - } catch { - await send(.delegate(.showToast(NekiToastItem("메모 저장에 실패했어요", style: .error)))) - } + try? await archiveClient.updatePhotoMemo(photoID: photoID, memo: limitedText) } case .clearAllMemoEditing: state.editingMemoText = "" return .none + // MARK: - Image Transform case .onTapTransform: + state.showDropDownMenu = false + guard let url = state.currentItem?.imageURL else { return .none } return .run { send in do { let image = try await withCheckedThrowingContinuation { continuation in - KingfisherManager.shared.retrieveImage(with: url, options: [ - .cacheOriginalImage - ]) { result in + KingfisherManager.shared.retrieveImage( + with: url, + options: [.cacheOriginalImage] + ) { result in switch result { - case .success(let value): continuation.resume(returning: value.image) - case .failure(let error): continuation.resume(throwing: error) + case .success(let value): + continuation.resume(returning: value.image) + case .failure(let error): + continuation.resume(throwing: error) } } } @@ -158,36 +164,71 @@ struct ArchivePhotoDetailFeature { case .imageFetchResponse(.failure): return .send(.delegate(.showToast(NekiToastItem("이미지를 불러오지 못했어요", style: .error)))) - case .onTapBackButton: - return .run { _ in await dismiss() } + // MARK: - Add To Album + case .onTapAddToAlbum: + state.showDropDownMenu = false + state.albumSelection = AlbumSelectionFeature.State(uploadCount: 1) + return .none + + case let .albumSelection(.presented(.delegate(delegateAction))): + switch delegateAction { + + case let .didSelectAlbum(albumId): +// let photoId = state.currentItemID +// state.albumSelection = nil +// state.isLoading = true +// + // TODO: - 어떤 식으로 업로드 할 건지 서버측과 논의 필요함 + // 그냥 킹피셔로 데이터 추출해서 새로운 파일로 업로드하는 것도 가능하긴 한데, 흠 +// return .run { send in +// await send(.addToAlbumResponse(Result { +// let isFavorite = albumId == -1 +// let targetFolderId = isFavorite ? nil : albumId +// +// try await archiveClient.registerPhotos( +// folderId: targetFolderId, +// uploads: [(mediaID: photoId, memo: String?.none, uploadMethod: PhotoUploadMethod.direct)], +// favorite: isFavorite +// ) +// })) +// } + return .none + + case .didTapCancel: + state.albumSelection = nil + return .none + + case let .showToast(toastItem): + return .send(.delegate(.showToast(toastItem))) + } + + case .addToAlbumResponse(.success): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진을 앨범에 추가했어요", style: .success)))) + + case .addToAlbumResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("앨범 추가에 실패했어요", style: .error)))) + + + // MARK: - 즐겨찾기 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 - case let .toggleFavoriteResponse(photoID, .failure): - state.photos[id: photoID]?.isFavorite.toggle() - return .send(.delegate(.showToast(NekiToastItem("즐겨찾기 변경에 실패했어요", style: .error)))) + // MARK: - 다운로드 case .onTapDownload: - guard let url = state.currentItem?.imageURL else { - return .none - } - + guard let url = state.currentItem?.imageURL else { return .none } state.isLoading = true return .run { send in @@ -204,22 +245,19 @@ struct ArchivePhotoDetailFeature { return .send(.delegate(.showToast(NekiToastItem("사진 저장에 실패했어요", style: .error)))) } + + // MARK: - 삭제 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 .deletePhotoResponse(.success): guard let deletedID = state.currentItem?.id else { return .none } let deletedIndex = state.photos.index(id: deletedID) - state.photos.remove(id: deletedID) if state.photos.isEmpty { @@ -234,7 +272,6 @@ struct ArchivePhotoDetailFeature { } else if let last = state.photos.last { state.currentItemID = last.id } - return .send(.delegate(.showToast(NekiToastItem("사진을 삭제했어요", style: .success)))) case .deletePhotoResponse(.failure): @@ -244,10 +281,7 @@ struct ArchivePhotoDetailFeature { return .none } } - .ifLet(\.$imageTransform, action: \.imageTransform) { - ImageTransformFeature() - } - + .ifLet(\.$imageTransform, action: \.imageTransform) { ImageTransformFeature() } + .ifLet(\.$albumSelection, action: \.albumSelection) { AlbumSelectionFeature() } } - } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift index f2df39c5..4978b0c9 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift @@ -34,8 +34,35 @@ struct ArchivePhotoDetailView: View { // 하단 메모 및 푸터 UI bottomContainer + + if store.isLoading { + LoadingView(message: "요청을 처리하고 있어요.") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if store.showDropDownMenu { + // 메뉴 외부 빈 공간 터치 시 드롭다운 닫기 + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + .onTapGesture { + store.send(.closeDropDownMenu) + } + + VStack(spacing: 0) { + HStack { + Spacer() + dropDownMenu + .padding(.top, 45) + .padding(.trailing, 20) + } + Spacer() + } + .zIndex(10) + } } .onChange(of: store.currentItemID) { _, _ in + store.send(.closeDropDownMenu) store.send(.binding(.set(\.isMemoVisible, false))) store.send(.toggleMemoExpanded(false)) store.send(.binding(.set(\.isMemoEditing, false))) @@ -61,6 +88,11 @@ struct ArchivePhotoDetailView: View { } } } + .fullScreenCover(item: $store.scope(state: \.albumSelection, action: \.albumSelection)) { selectionStore in + NavigationStack { + AlbumSelectionView(store: selectionStore) + } + } .nekiAlert( isPresented: $showDeleteAlert, style: .cancelable, @@ -90,16 +122,48 @@ extension ArchivePhotoDetailView { left: { NekiToolBar.back { store.send(.onTapBackButton) } }, center: { NekiToolBar.textCenter(store.formattedDate) }, right: { - #if DEBUG - NekiToolBar.icon(UIImage(systemName: "wand.and.stars")!, - action: { store.send(.onTapTransform) }) - #else - NekiToolBar.icon(.iconEllipsis ,action: { store.send(.onTapTransform) }) - #endif + Button { + store.send(.toggleDropDownMenu) + } label: { + Image(.iconEllipsis) + .frame(width: 24, height: 24) + .padding(8) + } } ) } + private var dropDownMenu: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + store.send(.onTapAddToAlbum) + } label: { + Text("앨범에 추가") + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + } + .frame(width: 158, height: 34, alignment: .leading) + .padding(.leading, 12) + .contentShape(Rectangle()) + + Button { + store.send(.onTapTransform) + } label: { + Text("이미지 변환") + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + } + .frame(width: 158, height: 34, alignment: .leading) + .padding(.leading, 12) + .contentShape(Rectangle()) + } + .padding(.vertical, 8) + .frame(width: 158, alignment: .topLeading) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.2), radius: 2.5, x: 0, y: 0) + } + private var photoTabView: some View { TabView(selection: $store.currentItemID) { ForEach(store.photos) { item in @@ -192,7 +256,7 @@ extension ArchivePhotoDetailView { .nekiFont(.body16SemiBold) .foregroundStyle(.gray800) } - + Button { store.send(.doneMemoEditing) isMemoEditingFocused = false diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/SelectUploadAlbumView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/SelectUploadAlbumView.swift index 550040e9..f800720b 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/SelectUploadAlbumView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/SelectUploadAlbumView.swift @@ -94,7 +94,6 @@ private extension SelectUploadAlbumView { Button { store.send(.tapConfirmUpload) } label: { - // 💡 에러 수정: pendingUploadImages 프로퍼티를 참조하도록 변경했습니다. Text("\(store.pendingUploadImages.count)장 업로드") .nekiFont(.body16SemiBold) .foregroundStyle(store.selectedAlbumId == nil ? .gray200 : .primary500) From 81a78abfe5ead1b34649250d7b0fe7bfe45c6218 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 22:48:20 +0900 Subject: [PATCH 05/17] =?UTF-8?q?[Chore]=20#202=20-=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B3=80=ED=99=98=EA=B8=B0=EB=8A=A5=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20Scheme=EC=97=90=EC=84=9C=EB=8A=94=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Sources/View/ArchivePhotoDetailView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift index 4978b0c9..bca08fe6 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift @@ -146,6 +146,9 @@ extension ArchivePhotoDetailView { .padding(.leading, 12) .contentShape(Rectangle()) + + // MARK: - 내부 테스트 전용 기능 + #if DEBUG Button { store.send(.onTapTransform) } label: { @@ -156,6 +159,8 @@ extension ArchivePhotoDetailView { .frame(width: 158, height: 34, alignment: .leading) .padding(.leading, 12) .contentShape(Rectangle()) + #endif + } .padding(.vertical, 8) .frame(width: 158, alignment: .topLeading) From 745ca254ef6566b1cda755a9a5e2efc1a57317d4 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 23:25:38 +0900 Subject: [PATCH 06/17] =?UTF-8?q?[Feat]=20#202=20-=20ArchiveImageFooter=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4,=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B6=84=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/ArchiveImageFooter.swift | 97 +++++++++++++++++-- .../icon_download.imageset/Contents.json | 2 +- .../download_disabled.svg | 0 .../icon_download_fill.imageset/Contents.json | 2 +- .../download_abled.svg | 0 .../icon_duplicate.imageset/Contents.json | 15 +++ .../icon_duplicate.svg | 9 ++ .../Contents.json | 15 +++ .../icon_duplicate_fill.svg | 9 ++ .../Common/icon_move.imageset/Contents.json | 15 +++ .../Common/icon_move.imageset/icon_move.svg | 6 ++ .../icon_move_fill.imageset/Contents.json | 15 +++ .../icon_move_fill.svg | 6 ++ 13 files changed, 181 insertions(+), 10 deletions(-) rename Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/{icon_download_fill.imageset => icon_download.imageset}/download_disabled.svg (100%) rename Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/{icon_download.imageset => icon_download_fill.imageset}/download_abled.svg (100%) create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/Contents.json create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/icon_duplicate.svg create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/Contents.json create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/icon_duplicate_fill.svg create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/Contents.json create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/icon_move.svg create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/Contents.json create mode 100644 Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/icon_move_fill.svg diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift index 92106eae..c8c4e9a4 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift @@ -7,55 +7,136 @@ import SwiftUI +public enum ArchiveFooterStyle { + case detail // 사진 상세 화면 (즐겨찾기, 메모 아이콘) + case selection // 사진 선택 화면 (다운로드, 복제, 이동, 삭제 + 텍스트) +} + struct ArchiveImageFooter: View { // MARK: - Properties - /// 버튼 활성화 여부 (선택 모드에서는 선택된 아이템 유무, 상세 모드에서는 항상 true) - let isEnabled: Bool + let style: ArchiveFooterStyle - /// 즐겨찾기 상태 (nil이면 버튼 숨김) + /// 버튼 활성화 여부 + let isEnabled: Bool + /// 즐겨찾기 상태 (상세 모드 전용) let isFavorite: Bool? + // 아이콘 액션 let onDownload: () -> Void let onDelete: () -> Void let onFavorite: (() -> Void)? let onTapMemo: (() -> Void)? + let onDuplicate: (() -> Void)? + let onMove: (() -> Void)? // MARK: - Init public init( + style: ArchiveFooterStyle = .detail, isEnabled: Bool = true, isFavorite: Bool? = nil, onDownload: @escaping () -> Void, onDelete: @escaping () -> Void, onFavorite: (() -> Void)? = nil, - onTapMemo: (() -> Void)? = nil + onTapMemo: (() -> Void)? = nil, + onDuplicate: (() -> Void)? = nil, + onMove: (() -> Void)? = nil ) { + self.style = style self.isEnabled = isEnabled self.isFavorite = isFavorite self.onDownload = onDownload self.onDelete = onDelete self.onFavorite = onFavorite self.onTapMemo = onTapMemo + self.onDuplicate = onDuplicate + self.onMove = onMove } // MARK: - Body var body: some View { + if style == .selection { + selectionModeFooter + } else { + detailModeFooter + } + } +} + +extension ArchiveImageFooter { + private var selectionModeFooter: some View { + HStack(alignment: .center, spacing: 0) { + selectionButton( + title: "다운로드", + icon: Image(isEnabled ? .iconDownloadFill : .iconDownload), + action: onDownload + ) + + selectionButton( + title: "사진 복제", + icon: Image(isEnabled ? .iconDuplicateFill : .iconDuplicate), + action: onDuplicate ?? {} + ) + + selectionButton( + title: "사진 이동", + icon: Image(isEnabled ? .iconMoveFill : .iconMove), + action: onMove ?? {} + ) + + selectionButton( + title: "삭제", + icon: Image(isEnabled ? .iconTrashFill : .iconTrash), + action: onDelete + ) + } + .padding(.top, 8) + .padding(.bottom, 10) + .background(.white) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(.gray75), + alignment: .top + ) + } + + // 버튼 공통 뷰 빌더 + @ViewBuilder + private func selectionButton(title: String, icon: Image, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 4) { + icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundStyle(isEnabled ? .gray700 : .gray200) + + Text(title) + .nekiFont(.body14Medium) + .foregroundStyle(isEnabled ? .gray700 : .gray400) + + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .disabled(!isEnabled) + } + + private var detailModeFooter: some View { HStack(alignment: .center, spacing: 0) { Button(action: onDownload) { Image(isEnabled ? .iconDownloadFill : .iconDownload) - .renderingMode(.template) - .foregroundStyle(isEnabled ? .gray700 : .gray100) + .foregroundStyle(isEnabled ? .gray700 : .gray200) } .disabled(!isEnabled) if let isFavorite = isFavorite, let onFavorite = onFavorite { Button(action: onFavorite) { Image(isFavorite ? .iconHeart28Fill : .iconHeart28Gray) - .renderingMode(.template) - .foregroundStyle(isFavorite ? .red : .gray700) } .padding(.leading, 16) } diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/Contents.json b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/Contents.json index 932a0cb0..df5ad55c 100644 --- a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/Contents.json +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "download_abled.svg", + "filename" : "download_disabled.svg", "idiom" : "universal" } ], diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/download_disabled.svg b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/download_disabled.svg similarity index 100% rename from Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/download_disabled.svg rename to Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/download_disabled.svg diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/Contents.json b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/Contents.json index df5ad55c..932a0cb0 100644 --- a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/Contents.json +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "download_disabled.svg", + "filename" : "download_abled.svg", "idiom" : "universal" } ], diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/download_abled.svg b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/download_abled.svg similarity index 100% rename from Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download.imageset/download_abled.svg rename to Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_download_fill.imageset/download_abled.svg diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/Contents.json b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/Contents.json new file mode 100644 index 00000000..e401a2bc --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icon_duplicate.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/icon_duplicate.svg b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/icon_duplicate.svg new file mode 100644 index 00000000..779da399 --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate.imageset/icon_duplicate.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/Contents.json b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/Contents.json new file mode 100644 index 00000000..ce1f3909 --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icon_duplicate_fill.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/icon_duplicate_fill.svg b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/icon_duplicate_fill.svg new file mode 100644 index 00000000..a0917367 --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_duplicate_fill.imageset/icon_duplicate_fill.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/Contents.json b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/Contents.json new file mode 100644 index 00000000..49f94391 --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icon_move.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/icon_move.svg b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/icon_move.svg new file mode 100644 index 00000000..509cf05c --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move.imageset/icon_move.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/Contents.json b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/Contents.json new file mode 100644 index 00000000..dbe67e1c --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icon_move_fill.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/icon_move_fill.svg b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/icon_move_fill.svg new file mode 100644 index 00000000..746e7d5d --- /dev/null +++ b/Neki-iOS/Shared/DesignSystem/Resources/Assets.xcassets/Common/icon_move_fill.imageset/icon_move_fill.svg @@ -0,0 +1,6 @@ + + + + + + From 8ceb20ab887f4c01336af603fae749a0a60a6882 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 23:30:56 +0900 Subject: [PATCH 07/17] =?UTF-8?q?[Chore]=20#202=20-=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20ArchiveImageFooter=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/View/ArchiveAlbumDetailView.swift | 9 ++++++++- .../Presentation/Sources/View/ArchiveAllPhotosView.swift | 9 ++++++++- .../Sources/View/ArchiveFavoriteAlbumView.swift | 9 ++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index 800fae2d..30c610cc 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -39,9 +39,16 @@ struct ArchiveAlbumDetailView: View { VStack { Spacer() ArchiveImageFooter( + style: .selection, isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, - onDelete: { deleteAlbumSheetPresented = true } + onDelete: { deleteAlbumSheetPresented = true }, + onDuplicate: { + // TODO: 사진 복제 액션 + }, + onMove: { + // TODO: 사진 이동 액션 + } ) } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift index 9350d682..1b72df94 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift @@ -34,9 +34,16 @@ struct ArchiveAllPhotosView: View { VStack { Spacer() ArchiveImageFooter( + style: .selection, isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, - onDelete: { showDeleteAlert = true } + onDelete: { showDeleteAlert = true }, + onDuplicate: { + // TODO: 사진 복제 액션 + }, + onMove: { + // TODO: 사진 이동 액션 + } ) } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift index f753fb36..a1485eb3 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift @@ -36,9 +36,16 @@ struct ArchiveFavoriteAlbumView: View { VStack { Spacer() ArchiveImageFooter( + style: .selection, isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, - onDelete: { showDeleteAlert = true } + onDelete: { showDeleteAlert = true }, + onDuplicate: { + // TODO: 사진 복제 액션 + }, + onMove: { + // TODO: 사진 이동 액션 + } ) } } From 3a092c402e0450a05311e263c277c1a30f090746 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Thu, 2 Apr 2026 23:59:57 +0900 Subject: [PATCH 08/17] =?UTF-8?q?[Style]=20#202=20-=20=EC=95=A8=EB=B2=94?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99&=EB=B3=B5=EC=A0=9C=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/ArchiveImageFooter.swift | 5 + .../Feature/ArchiveAlbumDetailFeature.swift | 195 +++++++++--------- .../Feature/ArchiveAllPhotosFeature.swift | 171 +++++++-------- .../Feature/ArchiveFavoriteAlbumFeature.swift | 167 ++++++++------- .../Sources/View/ArchiveAlbumDetailView.swift | 19 +- .../Sources/View/ArchiveAllPhotosView.swift | 17 +- .../View/ArchiveFavoriteAlbumView.swift | 17 +- 7 files changed, 311 insertions(+), 280 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift index c8c4e9a4..382efbbb 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Components/ArchiveImageFooter.swift @@ -7,6 +7,11 @@ import SwiftUI +public enum PhotoSelectionPurpose { + case duplicate + case move +} + public enum ArchiveFooterStyle { case detail // 사진 상세 화면 (즐겨찾기, 메모 아이콘) case selection // 사진 선택 화면 (다운로드, 복제, 이동, 삭제 + 텍스트) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift index 698b2f04..88606d9d 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift @@ -10,15 +10,14 @@ import Foundation @Reducer struct ArchiveAlbumDetailFeature { - @ObservableState struct State { - var photos: IdentifiedArrayOf = [] + @Presents var albumSelection: AlbumSelectionFeature.State? + var selectionPurpose: PhotoSelectionPurpose? + var photos: IdentifiedArrayOf = [] let album: AlbumItem - var showDropDownMenu: Bool = false - var selectedIDs: Set = [] var newAlbumTitle: String = "" @@ -29,16 +28,10 @@ struct ArchiveAlbumDetailFeature { } var isSelectionMode: Bool = false - - var filteredAlbumPhotos: IdentifiedArrayOf { - return photos - } - + var filteredAlbumPhotos: IdentifiedArrayOf { return photos } var isFetchingPhotos: Bool = false - var imagePicker = ImagePickerFeature.State(maxCount: 10, mediaType: .photoBooth, autoUpload: false) var isLoading: Bool = false - var hasSelectedItems: Bool { !selectedIDs.isEmpty } init(photos: IdentifiedArrayOf = [], album: AlbumItem) { @@ -63,11 +56,16 @@ struct ArchiveAlbumDetailFeature { case editAlbumResponse(Result) case imagePicker(ImagePickerFeature.Action) - case processUploadImages(entities: [ImageUploadEntity]) case registerPhotos(entities: [ImageUploadEntity]) case registerPhotosResponse(Result) + case onTapDuplicateButton + case onTapMoveButton + case albumSelection(PresentationAction) + case duplicatePhotosResponse(Result) + case movePhotosResponse(Result) + case onTapDownloadButton case downloadImagesResponse(successCount: Int) case onTapDeleteButton(option: ArchivePhotoDeleteOption) @@ -91,17 +89,13 @@ struct ArchiveAlbumDetailFeature { var body: some ReducerOf { BindingReducer() - Scope(state: \.imagePicker, action: \.imagePicker) { - ImagePickerFeature() - } + Scope(state: \.imagePicker, action: \.imagePicker) { ImagePickerFeature() } Reduce { state, action in switch action { - case .onTapBackButton: - return .run { _ in await dismiss() } + case .onTapBackButton: return .run { _ in await dismiss() } - case .onAppear: - return .send(.fetchPhotos) + case .onAppear: return .send(.fetchPhotos) case .toggleDropDownMenu: state.showDropDownMenu.toggle() @@ -113,9 +107,7 @@ struct ArchiveAlbumDetailFeature { case let .onTapFavorite(item): 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) @@ -125,15 +117,12 @@ struct ArchiveAlbumDetailFeature { } } - case .toggleFavoriteResponse(_, .success): - return .none + case .toggleFavoriteResponse(_, .success): return .none case let .toggleFavoriteResponse(photoID, .failure): state.photos[id: photoID]?.isFavorite.toggle() return .send(.delegate(.showToast(NekiToastItem("즐겨찾기 변경에 실패했어요", style: .error)))) - // MARK: - Image Upload - case let .imagePicker(.delegate(.imagesConverted(entities))): state.showDropDownMenu = false return .send(.processUploadImages(entities: entities)) @@ -146,15 +135,12 @@ struct ArchiveAlbumDetailFeature { case let .registerPhotos(entities): let albumId = state.album.id - return .run { send in - await send(.registerPhotosResponse( - Result { - let mediaIds = try await imageUploadClient.upload(entities, .photoBooth) - let uploads = mediaIds.map { (mediaID: $0, memo: String?.none, uploadMethod: PhotoUploadMethod.direct) } - try await archiveClient.registerPhotos(folderId: albumId, uploads: uploads ,favorite: false) - } - )) + await send(.registerPhotosResponse(Result { + let mediaIds = try await imageUploadClient.upload(entities, .photoBooth) + let uploads = mediaIds.map { (mediaID: $0, memo: String?.none, uploadMethod: PhotoUploadMethod.direct) } + try await archiveClient.registerPhotos(folderId: albumId, uploads: uploads ,favorite: false) + })) } case .registerPhotosResponse(.success): @@ -168,8 +154,6 @@ struct ArchiveAlbumDetailFeature { state.isLoading = false return .send(.delegate(.showToast(NekiToastItem("업로드에 실패했어요", style: .error)))) - // MARK: - Edit Album Action - case .onTapCancelEditAlbum: state.newAlbumTitle = state.album.title state.albumTitleErrorMessage = nil @@ -179,17 +163,11 @@ struct ArchiveAlbumDetailFeature { guard state.isConfirmButtonEnabled else { return .none } let title = state.newAlbumTitle.trimmingCharacters(in: .whitespacesAndNewlines) state.albumTitleErrorMessage = nil - return .run { [albumId = state.album.id] send in - await send(.editAlbumResponse(Result { - try await archiveClient.editAlbumName(albumId, title) - })) + await send(.editAlbumResponse(Result { try await archiveClient.editAlbumName(albumId, title) })) } - case .editAlbumResponse(.success): - return .run { send in - await send(.delegate(.showToast(NekiToastItem("앨범 이름을 변경했어요", style: .success)))) - } + case .editAlbumResponse(.success): return .send(.delegate(.showToast(NekiToastItem("앨범 이름을 변경했어요", style: .success)))) case .editAlbumResponse(.failure): state.newAlbumTitle = state.album.title @@ -197,37 +175,16 @@ struct ArchiveAlbumDetailFeature { case .fetchPhotos: state.isFetchingPhotos = true - return .run { [albumId = state.album.id] send in - await send(.photoListResponse( - Result { - try await archiveClient.fetchPhotoList( - folderId: albumId, - size: 20, - sortOrder: nil - ) - } - )) + await send(.photoListResponse(Result { try await archiveClient.fetchPhotoList(folderId: albumId, size: 20, sortOrder: nil) })) } case let .photoListResponse(.success(entities)): state.isFetchingPhotos = false - let currentAlbumId = state.album.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 - ) + 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 @@ -235,8 +192,7 @@ struct ArchiveAlbumDetailFeature { state.isFetchingPhotos = false return .send(.delegate(.showToast(NekiToastItem("사진을 불러오지 못했어요", style: .error)))) - case .loadMorePhotos: - return .send(.fetchPhotos) + case .loadMorePhotos: return .send(.fetchPhotos) case .onTapSelectButton: state.showDropDownMenu = false @@ -250,20 +206,80 @@ struct ArchiveAlbumDetailFeature { case let .imageTapped(item): if state.isSelectionMode { - if state.selectedIDs.contains(item.id) { - state.selectedIDs.remove(item.id) - } else { - state.selectedIDs.insert(item.id) - } + if state.selectedIDs.contains(item.id) { state.selectedIDs.remove(item.id) } + else { state.selectedIDs.insert(item.id) } } return .none + case .onTapDuplicateButton: + state.selectionPurpose = .duplicate + state.albumSelection = AlbumSelectionFeature.State(uploadCount: state.selectedIDs.count) + return .none + + case .onTapMoveButton: + state.selectionPurpose = .move + state.albumSelection = AlbumSelectionFeature.State(uploadCount: state.selectedIDs.count) + return .none + + case let .albumSelection(.presented(.delegate(delegateAction))): + switch delegateAction { + case let .didSelectAlbum(albumId): + let purpose = state.selectionPurpose + let selectedIDs = Array(state.selectedIDs) + + state.albumSelection = nil + state.selectionPurpose = nil + state.isLoading = true + + return .run { send in + if purpose == .duplicate { + // TODO: API 붙이기 + try? await Task.sleep(for: .seconds(1)) // 테스트용 임시 딜레이 + await send(.duplicatePhotosResponse(.success(()))) + } else { + // TODO: API 붙이기 + try? await Task.sleep(for: .seconds(1)) // 테스트용 임시 딜레이 + await send(.movePhotosResponse(.success(()))) + } + } + + case .didTapCancel: + state.albumSelection = nil + state.selectionPurpose = nil + return .none + + case let .showToast(toastItem): + return .send(.delegate(.showToast(toastItem))) + } + + case .duplicatePhotosResponse(.success): + state.isLoading = false + state.isSelectionMode = false + state.selectedIDs.removeAll() + return .send(.delegate(.showToast(NekiToastItem("사진을 앨범에 추가했어요", style: .success)))) + + case .duplicatePhotosResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진 추가에 실패했어요", style: .error)))) + + case .movePhotosResponse(.success): + state.isLoading = false + state.isSelectionMode = false + state.selectedIDs.removeAll() + return .merge( + .send(.delegate(.showToast(NekiToastItem("사진을 앨범으로 이동했어요", style: .success)))), + .send(.fetchPhotos) + ) + + case .movePhotosResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진 이동에 실패했어요", style: .error)))) + + case .onTapDownloadButton: guard !state.selectedIDs.isEmpty else { return .none } state.isLoading = true - let urls = state.selectedIDs.compactMap { state.photos[id: $0]?.imageURL } - return .run { send in let count = try await imageDownloadClient.downloadImages(urls: urls) await send(.downloadImagesResponse(successCount: count)) @@ -273,46 +289,33 @@ struct ArchiveAlbumDetailFeature { state.isLoading = false state.isSelectionMode = false state.selectedIDs.removeAll() - - if count > 0 { - return .send(.delegate(.showToast(NekiToastItem("사진을 갤러리에 다운로드했어요", style: .success)))) - } else { - return .send(.delegate(.showToast(NekiToastItem("사진 저장에 실패했어요", style: .error)))) - } + if count > 0 { return .send(.delegate(.showToast(NekiToastItem("사진을 갤러리에 다운로드했어요", style: .success)))) } + else { return .send(.delegate(.showToast(NekiToastItem("사진 저장에 실패했어요", style: .error)))) } case let .onTapDeleteButton(option): guard !state.selectedIDs.isEmpty else { return .none } - let selectedIDs = Array(state.selectedIDs) let albumID = state.album.id - return .run { send in - await send(.deletePhotosResponse( - Result { - if option == .fromAlbumOnly { - try await archiveClient.excludePhotosInAlbum(albumID, selectedIDs) - } else { - try await archiveClient.deletePhotoList(selectedIDs) - } - } - )) + await send(.deletePhotosResponse(Result { + if option == .fromAlbumOnly { try await archiveClient.excludePhotosInAlbum(albumID, selectedIDs) } + else { try await archiveClient.deletePhotoList(selectedIDs) } + })) } case .deletePhotosResponse(.success): let idsToDelete = state.selectedIDs state.photos.removeAll { idsToDelete.contains($0.id) } - state.isSelectionMode = false state.selectedIDs.removeAll() - return .send(.delegate(.showToast(NekiToastItem("사진을 삭제했어요", style: .success)))) case .deletePhotosResponse(.failure): return .send(.delegate(.showToast(NekiToastItem("사진을 삭제하지 못했어요", style: .error)))) - default: - return .none + default: return .none } } + .ifLet(\.$albumSelection, action: \.albumSelection) { AlbumSelectionFeature() } } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swift index d041180a..0790f79b 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAllPhotosFeature.swift @@ -10,62 +10,52 @@ import Foundation @Reducer struct ArchiveAllPhotosFeature { - @ObservableState struct State { + @Presents var albumSelection: AlbumSelectionFeature.State? + var selectionPurpose: PhotoSelectionPurpose? + var photos: IdentifiedArrayOf = [] var selectedIDs: Set = [] - - var selectedSortedTime: String = "최신순" // "최신순" == DESC, "오래된순" == ASC + var selectedSortedTime: String = "최신순" var isSelectedFavorite: Bool = false var isSelectionMode: Bool = false - var isFetchingPhotos: Bool = false - - // 선택된 사진이 있는지 여부 - var hasSelectedItems: Bool { - return !selectedIDs.isEmpty - } - + var hasSelectedItems: Bool { return !selectedIDs.isEmpty } var filteredItems: IdentifiedArrayOf { let filtered = isSelectedFavorite ? photos.filter { $0.isFavorite } : photos return IdentifiedArray(uniqueElements: filtered) } + var isLoading: Bool = false + } enum Action: BindableAction { case binding(BindingAction) - - // View Life Cycle Action case onAppear - - // User Action case onTapBackButton case onTapSelectButton case onTapCancelSelectButton - case onTapFavorite(item: ArchiveImageItem) case toggleFavoriteResponse(photoID: Int, result: Result) - case onTapDownloadButton case downloadImagesResponse(successCount: Int) - - // Delete Action case onTapDeleteButton case deletePhotosLocally(ids: [Int]) - - // Filter Action case onTapFilterNewest case onTapFilterOldest case onTapFavoriteButton - - // Fetch Photo Action case fetchPhotos case photoListResponse(Result<[PhotoEntity], Error>) case loadMorePhotos - case imageTapped(ArchiveImageItem) + case onTapDuplicateButton + case onTapMoveButton + case albumSelection(PresentationAction) + case duplicatePhotosResponse(Result) + case movePhotosResponse(Result) + case delegate(Delegate) enum Delegate { case showToast(NekiToastItem) @@ -81,16 +71,9 @@ struct ArchiveAllPhotosFeature { Reduce { state, action in switch action { + case .onAppear: return .send(.fetchPhotos) - // MARK: - View Life Cycle Action - - case .onAppear: - return .send(.fetchPhotos) - - // MARK: - User Action - - case .onTapBackButton: - return .run { _ in await dismiss() } + case .onTapBackButton: return .run { _ in await dismiss() } case .onTapSelectButton: state.isSelectionMode = true @@ -103,9 +86,7 @@ struct ArchiveAllPhotosFeature { case let .onTapFavorite(item): 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) @@ -115,8 +96,7 @@ struct ArchiveAllPhotosFeature { } } - case .toggleFavoriteResponse(_, .success): - return .none + case .toggleFavoriteResponse(_, .success): return .none case let .toggleFavoriteResponse(photoID, .failure): state.photos[id: photoID]?.isFavorite.toggle() @@ -124,26 +104,19 @@ struct ArchiveAllPhotosFeature { case .onTapDownloadButton: guard !state.selectedIDs.isEmpty else { return .none } - + state.isLoading = true let urls = state.selectedIDs.compactMap { state.photos[id: $0]?.imageURL } - return .run { send in let count = try await imageDownloadClient.downloadImages(urls: urls) await send(.downloadImagesResponse(successCount: count)) } case let .downloadImagesResponse(count): + state.isLoading = false state.isSelectionMode = false state.selectedIDs.removeAll() - - if count > 0 { - return .send(.delegate(.showToast(NekiToastItem("사진을 갤러리에 다운로드했어요", style: .success)))) - } else { - return .send(.delegate(.showToast(NekiToastItem("사진 저장에 실패했어요", style: .error)))) - } - - - // MARK: - Delete Action + if count > 0 { return .send(.delegate(.showToast(NekiToastItem("사진을 갤러리에 다운로드했어요", style: .success)))) } + else { return .send(.delegate(.showToast(NekiToastItem("사진 저장에 실패했어요", style: .error)))) } case .onTapDeleteButton: return .run { [selectedIDs = state.selectedIDs] send in @@ -153,15 +126,10 @@ struct ArchiveAllPhotosFeature { case let .deletePhotosLocally(ids): state.photos.removeAll { ids.contains($0.id) } - state.isSelectionMode = false state.selectedIDs.removeAll() - return .send(.delegate(.showToast(NekiToastItem("사진을 삭제했어요", style: .success)))) - - // MARK: - Filter Action - case .onTapFilterNewest: if state.selectedSortedTime == "최신순" { return .none } state.selectedSortedTime = "최신순" @@ -178,40 +146,19 @@ struct ArchiveAllPhotosFeature { state.isSelectedFavorite.toggle() return .none - - // MARK: - Fetch Photo Action - case .fetchPhotos: guard !state.isFetchingPhotos else { return .none } state.isFetchingPhotos = true - - // "최신순" -> "DESC", "오래된순" -> "ASC" 변환 let sortOrder = state.selectedSortedTime == "최신순" ? "DESC" : "ASC" - return .run { send in - await send(.photoListResponse( - Result { - try await archiveClient.fetchPhotoList(folderId: nil, size: 20, sortOrder: sortOrder) - } - )) + await send(.photoListResponse(Result { try await archiveClient.fetchPhotoList(folderId: nil, size: 20, sortOrder: sortOrder) })) } case let .photoListResponse(.success(entities)): state.isFetchingPhotos = false - let newItems = entities.map { entity in - ArchiveImageItem( - id: entity.photoID, - imageURLString: entity.imageURL, - isFavorite: entity.isfavorite, - date: entity.createdAt.toISO8601Date(), - folderId: entity.folderID, - memo: entity.memo ?? "", - width: entity.width, - height: entity.height - ) + ArchiveImageItem(id: entity.photoID, imageURLString: entity.imageURL, isFavorite: entity.isfavorite, date: entity.createdAt.toISO8601Date(), folderId: entity.folderID, memo: entity.memo ?? "", width: entity.width, height: entity.height) } - state.photos = IdentifiedArray(uniqueElements: newItems) return .none @@ -219,24 +166,78 @@ struct ArchiveAllPhotosFeature { state.isFetchingPhotos = false return .send(.delegate(.showToast(NekiToastItem("사진을 불러오지 못했어요", style: .error)))) - case .loadMorePhotos: - return .send(.fetchPhotos) + case .loadMorePhotos: return .send(.fetchPhotos) case let .imageTapped(item): if state.isSelectionMode { - if state.selectedIDs.contains(item.id) { - state.selectedIDs.remove(item.id) - } else { - state.selectedIDs.insert(item.id) - } - } else { - // 상세 화면 이동 로직 (Coordinator에서 처리) + if state.selectedIDs.contains(item.id) { state.selectedIDs.remove(item.id) } + else { state.selectedIDs.insert(item.id) } } return .none - default: + case .onTapDuplicateButton: + state.selectionPurpose = .duplicate + state.albumSelection = AlbumSelectionFeature.State(uploadCount: state.selectedIDs.count) return .none + + case .onTapMoveButton: + state.selectionPurpose = .move + state.albumSelection = AlbumSelectionFeature.State(uploadCount: state.selectedIDs.count) + return .none + + case let .albumSelection(.presented(.delegate(delegateAction))): + switch delegateAction { + case let .didSelectAlbum(albumId): + let purpose = state.selectionPurpose + + state.isLoading = true + state.albumSelection = nil + state.selectionPurpose = nil + + return .run { send in + if purpose == .duplicate { + // TODO: API 붙이기 + try? await Task.sleep(for: .seconds(1)) // 테스트용 임시 딜레이 + await send(.duplicatePhotosResponse(.success(()))) + } else { + // TODO: API 붙이기 + try? await Task.sleep(for: .seconds(1)) // 테스트용 임시 딜레이 + await send(.movePhotosResponse(.success(()))) + } + } + + case .didTapCancel: + state.albumSelection = nil + state.selectionPurpose = nil + return .none + + case let .showToast(toastItem): + return .send(.delegate(.showToast(toastItem))) + } + + case .duplicatePhotosResponse(.success): + state.isLoading = false + state.isSelectionMode = false + state.selectedIDs.removeAll() + return .send(.delegate(.showToast(NekiToastItem("사진을 앨범에 추가했어요", style: .success)))) + + case .duplicatePhotosResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진 추가에 실패했어요", style: .error)))) + + case .movePhotosResponse(.success): + state.isLoading = false + state.isSelectionMode = false + state.selectedIDs.removeAll() + return .send(.delegate(.showToast(NekiToastItem("사진을 앨범으로 이동했어요", style: .success)))) + + case .movePhotosResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진 이동에 실패했어요", style: .error)))) + + default: return .none } } + .ifLet(\.$albumSelection, action: \.albumSelection) { AlbumSelectionFeature() } } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swift index 871c6ede..5a0eb4e4 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveFavoriteAlbumFeature.swift @@ -10,58 +10,52 @@ import SwiftUI @Reducer struct ArchiveFavoriteAlbumFeature { - @ObservableState struct State { + @Presents var albumSelection: AlbumSelectionFeature.State? + var selectionPurpose: PhotoSelectionPurpose? + var photos: IdentifiedArrayOf = [] let album: AlbumItem - var showDropDownMenu: Bool = false - var selectedIDs: Set = [] var isSelectionMode: Bool = false - var imagePicker = ImagePickerFeature.State(maxCount: 10, mediaType: .photoBooth, autoUpload: false) var isLoading: Bool = false - var isFetchingPhotos: Bool = false - var hasSelectedItems: Bool { !selectedIDs.isEmpty } } enum Action: BindableAction { case binding(BindingAction) - case onAppear case toggleDropDownMenu case closeDropDownMenu - case onTapFavorite(item: ArchiveImageItem) case toggleFavoriteResponse(photoID: Int, result: Result) - case fetchFavoritePhotos case favoritePhotoListResponse(Result<[PhotoEntity], Error>) case loadMorePhotos - case onTapBackButton case onTapSelectButton case onTapCancelSelectButton - case onTapDownloadButton case downloadImagesResponse(successCount: Int) - case imagePicker(ImagePickerFeature.Action) - case processUploadImages(entities: [ImageUploadEntity]) case registerPhotos(entities: [ImageUploadEntity]) case registerPhotosResponse(Result) - case onTapDeleteButton case deletePhotos case deletePhotosResponse(Result) - case imageTapped(ArchiveImageItem) + case onTapDuplicateButton + case onTapMoveButton + case albumSelection(PresentationAction) + case duplicatePhotosResponse(Result) + case movePhotosResponse(Result) + case delegate(Delegate) enum Delegate { case showToast(NekiToastItem) @@ -76,17 +70,13 @@ struct ArchiveFavoriteAlbumFeature { var body: some ReducerOf { BindingReducer() - Scope(state: \.imagePicker, action: \.imagePicker) { - ImagePickerFeature() - } + Scope(state: \.imagePicker, action: \.imagePicker) { ImagePickerFeature() } Reduce { state, action in switch action { - case .onTapBackButton: - return .run { _ in await dismiss() } + case .onTapBackButton: return .run { _ in await dismiss() } - case .onAppear: - return .send(.fetchFavoritePhotos) + case .onAppear: return .send(.fetchFavoritePhotos) case .toggleDropDownMenu: state.showDropDownMenu.toggle() @@ -98,9 +88,7 @@ struct ArchiveFavoriteAlbumFeature { case let .onTapFavorite(item): 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) @@ -110,15 +98,12 @@ struct ArchiveFavoriteAlbumFeature { } } - case .toggleFavoriteResponse(_, .success): - return .none + case .toggleFavoriteResponse(_, .success): return .none case let .toggleFavoriteResponse(photoID, .failure): state.photos[id: photoID]?.isFavorite.toggle() return .send(.delegate(.showToast(NekiToastItem("즐겨찾기 변경에 실패했어요", style: .error)))) - // MARK: - Image Upload - case let .imagePicker(.delegate(.imagesConverted(entities))): state.showDropDownMenu = false return .send(.processUploadImages(entities: entities)) @@ -131,13 +116,11 @@ struct ArchiveFavoriteAlbumFeature { case let .registerPhotos(entities): return .run { send in - await send(.registerPhotosResponse( - Result { - let mediaIds = try await imageUploadClient.upload(entities, .photoBooth) - let uploads = mediaIds.map { (mediaID: $0, memo: String?.none, uploadMethod: PhotoUploadMethod.direct) } - try await archiveClient.registerPhotos(folderId: nil, uploads: uploads, favorite: true) - } - )) + await send(.registerPhotosResponse(Result { + let mediaIds = try await imageUploadClient.upload(entities, .photoBooth) + let uploads = mediaIds.map { (mediaID: $0, memo: String?.none, uploadMethod: PhotoUploadMethod.direct) } + try await archiveClient.registerPhotos(folderId: nil, uploads: uploads, favorite: true) + })) } case .registerPhotosResponse(.success): @@ -154,33 +137,16 @@ struct ArchiveFavoriteAlbumFeature { case .fetchFavoritePhotos: guard !state.isFetchingPhotos else { return .none } state.isFetchingPhotos = true - return .run { send in - await send(.favoritePhotoListResponse( - Result { - try await archiveClient.fetchFavoritePhotoList(20, "DESC") - } - )) + await send(.favoritePhotoListResponse(Result { try await archiveClient.fetchFavoritePhotoList(20, "DESC") })) } case let .favoritePhotoListResponse(.success(result)): state.isFetchingPhotos = false - let currentAlbumId = state.album.id - let newItems = result.map { entity in - ArchiveImageItem( - id: entity.photoID, - imageURLString: entity.imageURL, - isFavorite: true, - date: entity.createdAt.toISO8601Date(), - folderId: currentAlbumId, - memo: entity.memo ?? "", - width: entity.width, - height: entity.height - ) + ArchiveImageItem(id: entity.photoID, imageURLString: entity.imageURL, isFavorite: true, date: entity.createdAt.toISO8601Date(), folderId: currentAlbumId, memo: entity.memo ?? "", width: entity.width, height: entity.height) } - state.photos = IdentifiedArray(uniqueElements: newItems) return .none @@ -188,8 +154,7 @@ struct ArchiveFavoriteAlbumFeature { state.isFetchingPhotos = false return .send(.delegate(.showToast(NekiToastItem("사진을 불러오지 못했어요", style: .error)))) - case .loadMorePhotos: - return .send(.fetchFavoritePhotos) + case .loadMorePhotos: return .send(.fetchFavoritePhotos) case .onTapSelectButton: state.showDropDownMenu = false @@ -203,19 +168,77 @@ struct ArchiveFavoriteAlbumFeature { case let .imageTapped(item): if state.isSelectionMode { - if state.selectedIDs.contains(item.id) { - state.selectedIDs.remove(item.id) - } else { - state.selectedIDs.insert(item.id) - } + if state.selectedIDs.contains(item.id) { state.selectedIDs.remove(item.id) } + else { state.selectedIDs.insert(item.id) } } return .none + case .onTapDuplicateButton: + state.selectionPurpose = .duplicate + state.albumSelection = AlbumSelectionFeature.State(uploadCount: state.selectedIDs.count) + return .none + + case .onTapMoveButton: + state.selectionPurpose = .move + state.albumSelection = AlbumSelectionFeature.State(uploadCount: state.selectedIDs.count) + return .none + + case let .albumSelection(.presented(.delegate(delegateAction))): + switch delegateAction { + case let .didSelectAlbum(albumId): + let purpose = state.selectionPurpose + + state.albumSelection = nil + state.selectionPurpose = nil + state.isLoading = true + + return .run { send in + if purpose == .duplicate { + // TODO: API 붙이기 + try? await Task.sleep(for: .seconds(1)) // 테스트용 임시 딜레이 + await send(.duplicatePhotosResponse(.success(()))) + } else { + // TODO: API 붙이기 + try? await Task.sleep(for: .seconds(1)) // 테스트용 임시 딜레이 + await send(.movePhotosResponse(.success(()))) + } + } + + case .didTapCancel: + state.albumSelection = nil + state.selectionPurpose = nil + return .none + + case let .showToast(toastItem): + return .send(.delegate(.showToast(toastItem))) + } + + case .duplicatePhotosResponse(.success): + state.isLoading = false + state.isSelectionMode = false + state.selectedIDs.removeAll() + return .send(.delegate(.showToast(NekiToastItem("사진을 앨범에 복사했어요", style: .success)))) + + case .duplicatePhotosResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진 추가에 실패했어요", style: .error)))) + + case .movePhotosResponse(.success): + state.isLoading = false + state.isSelectionMode = false + state.selectedIDs.removeAll() + return .merge( + .send(.delegate(.showToast(NekiToastItem("사진을 앨범으로 이동했어요", style: .success)))), + .send(.fetchFavoritePhotos) + ) + + case .movePhotosResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진 이동에 실패했어요", style: .error)))) + case .onTapDownloadButton: guard !state.selectedIDs.isEmpty else { return .none } - let urls = state.selectedIDs.compactMap { state.photos[id: $0]?.imageURL } - return .run { send in let count = try await imageDownloadClient.downloadImages(urls: urls) await send(.downloadImagesResponse(successCount: count)) @@ -224,12 +247,8 @@ struct ArchiveFavoriteAlbumFeature { case let .downloadImagesResponse(count): state.isSelectionMode = false state.selectedIDs.removeAll() - - if count > 0 { - return .send(.delegate(.showToast(NekiToastItem("사진을 갤러리에 다운로드했어요", style: .success)))) - } else { - return .send(.delegate(.showToast(NekiToastItem("사진 저장에 실패했어요", style: .error)))) - } + if count > 0 { return .send(.delegate(.showToast(NekiToastItem("사진을 갤러리에 다운로드했어요", style: .success)))) } + else { return .send(.delegate(.showToast(NekiToastItem("사진 저장에 실패했어요", style: .error)))) } case .onTapDeleteButton: guard !state.selectedIDs.isEmpty else { return .none } @@ -238,26 +257,22 @@ struct ArchiveFavoriteAlbumFeature { case .deletePhotos: let idsToDelete = Array(state.selectedIDs) return .run { send in - await send(.deletePhotosResponse(Result { - try await archiveClient.deletePhotoList(idsToDelete) - })) + await send(.deletePhotosResponse(Result { try await archiveClient.deletePhotoList(idsToDelete) })) } case .deletePhotosResponse(.success): let idsToRemove = state.selectedIDs state.photos.removeAll { idsToRemove.contains($0.id) } - state.isSelectionMode = false state.selectedIDs.removeAll() - return .send(.delegate(.showToast(NekiToastItem("사진을 삭제했어요", style: .success)))) case .deletePhotosResponse(.failure): return .send(.delegate(.showToast(NekiToastItem("사진을 삭제하지 못했어요", style: .error)))) - default: - return .none + default: return .none } } + .ifLet(\.$albumSelection, action: \.albumSelection) { AlbumSelectionFeature() } } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index 30c610cc..953ba808 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -43,21 +43,22 @@ struct ArchiveAlbumDetailView: View { isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, onDelete: { deleteAlbumSheetPresented = true }, - onDuplicate: { - // TODO: 사진 복제 액션 - }, - onMove: { - // TODO: 사진 이동 액션 - } + onDuplicate: { store.send(.onTapDuplicateButton) }, + onMove: { store.send(.onTapMoveButton) } ) } } if store.isLoading { - LoadingView(message: "사진을 업로드하고 있어요.") + LoadingView(message: "작업을 수행하고 있어요.") } } .task { await store.send(.onAppear).finish() } + .fullScreenCover(item: $store.scope(state: \.albumSelection, action: \.albumSelection)) { selectionStore in + NavigationStack { + AlbumSelectionView(store: selectionStore) + } + } .sheet(isPresented: $deleteAlbumSheetPresented) { ArchiveDeleteSheet( initialOption: .fromAlbumOnly, @@ -156,7 +157,7 @@ private extension ArchiveAlbumDetailView { .frame(width: 120, height: 34, alignment: .leading) .padding(.leading, 12) .contentShape(Rectangle()) - + NekiImagePicker(store: store.scope(state: \.imagePicker, action: \.imagePicker)) { Text("사진 추가") .nekiFont(.body16Medium) @@ -165,7 +166,7 @@ private extension ArchiveAlbumDetailView { .frame(width: 120, height: 34, alignment: .leading) .padding(.leading, 12) .contentShape(Rectangle()) - + Button { store.send(.closeDropDownMenu) editAlbumNameSheetPresented = true diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift index 1b72df94..e087d7a4 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift @@ -38,16 +38,16 @@ struct ArchiveAllPhotosView: View { isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, onDelete: { showDeleteAlert = true }, - onDuplicate: { - // TODO: 사진 복제 액션 - }, - onMove: { - // TODO: 사진 이동 액션 - } + onDuplicate: { store.send(.onTapDuplicateButton) }, + onMove: { store.send(.onTapMoveButton) } ) } } + if store.isLoading { + LoadingView(message: "작업을 수행하고 있어요.") + } + } .nekiToolbar( left: { NekiToolBar.back(action: { store.send(.onTapBackButton) }) }, @@ -76,6 +76,11 @@ struct ArchiveAllPhotosView: View { .task { await store.send(.onAppear).finish() } + .fullScreenCover(item: $store.scope(state: \.albumSelection, action: \.albumSelection)) { selectionStore in + NavigationStack { + AlbumSelectionView(store: selectionStore) + } + } } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift index a1485eb3..e7743cf8 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift @@ -40,22 +40,23 @@ struct ArchiveFavoriteAlbumView: View { isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, onDelete: { showDeleteAlert = true }, - onDuplicate: { - // TODO: 사진 복제 액션 - }, - onMove: { - // TODO: 사진 이동 액션 - } + onDuplicate: { store.send(.onTapDuplicateButton) }, + onMove: { store.send(.onTapMoveButton) } ) } } if store.isLoading { - LoadingView(message: "사진을 업로드하고 있어요.") + LoadingView(message: "작업을 수행하고 있어요.") } } .task { await store.send(.onAppear).finish() } + .fullScreenCover(item: $store.scope(state: \.albumSelection, action: \.albumSelection)) { selectionStore in + NavigationStack { + AlbumSelectionView(store: selectionStore) + } + } .nekiAlert( isPresented: $showDeleteAlert, style: .cancelable, @@ -128,7 +129,7 @@ private extension ArchiveFavoriteAlbumView { .frame(width: 120, height: 34, alignment: .leading) .padding(.leading, 12) .contentShape(Rectangle()) - + NekiImagePicker(store: store.scope(state: \.imagePicker, action: \.imagePicker)) { Text("사진 추가") .nekiFont(.body16Medium) From eb3c60b0ce6e050902d3361d11e7659200ab4c85 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 00:16:42 +0900 Subject: [PATCH 09/17] =?UTF-8?q?[Feat]=20#202=20-=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/ArchiveAlbumDetailFeature.swift | 25 ++++++++++++++ .../Sources/View/ArchiveAlbumDetailView.swift | 33 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift index 88606d9d..01b0131e 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift @@ -68,8 +68,15 @@ struct ArchiveAlbumDetailFeature { case onTapDownloadButton case downloadImagesResponse(successCount: Int) + + // 사진 삭제 액션 case onTapDeleteButton(option: ArchivePhotoDeleteOption) case deletePhotosResponse(Result) + + // 앨범 삭제 액션 + case onTapExecuteDeleteAlbum(option: ArchiveAlbumDeleteOption) + case deleteAlbumResponse(Result) + case fetchPhotos case photoListResponse(Result<[PhotoEntity], Error>) case loadMorePhotos @@ -313,6 +320,24 @@ struct ArchiveAlbumDetailFeature { case .deletePhotosResponse(.failure): return .send(.delegate(.showToast(NekiToastItem("사진을 삭제하지 못했어요", style: .error)))) + case let .onTapExecuteDeleteAlbum(option): + let shouldDeletePhotos = (option == .withPhotos) + let albumId = state.album.id + return .run { send in + await send(.deleteAlbumResponse(Result { + try await archiveClient.deleteFolders([albumId], shouldDeletePhotos) + })) + } + + case .deleteAlbumResponse(.success): + return .run { send in + await send(.delegate(.showToast(NekiToastItem("앨범을 삭제했어요", style: .success)))) + await dismiss() + } + + case .deleteAlbumResponse(.failure): + return .send(.delegate(.showToast(NekiToastItem("앨범을 삭제하지 못했어요", style: .error)))) + default: return .none } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index 953ba808..002f071e 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -15,6 +15,7 @@ struct ArchiveAlbumDetailView: View { @State private var lastDragPoint: CGFloat = 0 @State var deleteAlbumSheetPresented: Bool = false @State var editAlbumNameSheetPresented: Bool = false + @State var deleteEntireAlbumSheetPresented: Bool = false var body: some View { ZStack(alignment: .topTrailing) { @@ -59,6 +60,7 @@ struct ArchiveAlbumDetailView: View { AlbumSelectionView(store: selectionStore) } } + // 사진 삭제 시트 .sheet(isPresented: $deleteAlbumSheetPresented) { ArchiveDeleteSheet( initialOption: .fromAlbumOnly, @@ -76,6 +78,25 @@ struct ArchiveAlbumDetailView: View { .presentationDetents([.height(280)]) .presentationCornerRadius(20) } + // 앨범 삭제 시트 + .sheet(isPresented: $deleteEntireAlbumSheetPresented) { + ArchiveDeleteSheet( + initialOption: .withPhotos, + title: "앨범을 삭제하시겠어요?", + firstOption: (.withPhotos, "사진까지 함께 삭제"), + secondOption: (.albumOnly, "사진은 유지하고 앨범만 삭제"), + onCancel: { + deleteEntireAlbumSheetPresented = false + }, + onConfirm: { selectedOption in + store.send(.onTapExecuteDeleteAlbum(option: selectedOption)) + deleteEntireAlbumSheetPresented = false + } + ) + .presentationDetents([.height(280)]) + .presentationCornerRadius(20) + } + // 앨범 이름 수정 시트 .sheet(isPresented: $editAlbumNameSheetPresented) { ArchiveAlbumInputSheet( style: .edit, @@ -178,6 +199,18 @@ private extension ArchiveAlbumDetailView { .frame(width: 120, height: 34, alignment: .leading) .padding(.leading, 12) .contentShape(Rectangle()) + + Button { + store.send(.closeDropDownMenu) + deleteEntireAlbumSheetPresented = true + } label: { + Text("앨범 삭제") + .nekiFont(.body16Medium) + .foregroundStyle(.primary500) // TODO: - 위험한 액션이니 빨간색 어떠냐고 피그마 문의 남김. 답변에 따라 수정가능성 있음 + } + .frame(width: 120, height: 34, alignment: .leading) + .padding(.leading, 12) + .contentShape(Rectangle()) } .padding(.vertical, 8) .frame(width: 120, alignment: .topLeading) From 82b2b851e9ab7aff044b4b090de6371ebc20c8f9 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 00:19:26 +0900 Subject: [PATCH 10/17] =?UTF-8?q?[Chore]=20#202=20-=20=EC=82=AC=EC=A7=84?= =?UTF-8?q?=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/View/ArchiveAlbumDetailView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index 002f071e..1872ea62 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -188,6 +188,17 @@ private extension ArchiveAlbumDetailView { .padding(.leading, 12) .contentShape(Rectangle()) + Button { + // 사진 가져오기 액션 연결 + } label: { + Text("사진 가져오기") + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + } + .frame(width: 120, height: 34, alignment: .leading) + .padding(.leading, 12) + .contentShape(Rectangle()) + Button { store.send(.closeDropDownMenu) editAlbumNameSheetPresented = true From 72722a64881b885a5510baa43366cfb51751cd00 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 01:59:04 +0900 Subject: [PATCH 11/17] =?UTF-8?q?[Feat]=20#202=20-=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/ArchiveAlbumDetailFeature.swift | 40 ++++ .../Sources/Feature/PhotoImportFeature.swift | 164 ++++++++++++++++ .../Sources/View/ArchiveAlbumDetailView.swift | 7 +- .../Sources/View/PhotoImportView.swift | 180 ++++++++++++++++++ 4 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift create mode 100644 Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift index 01b0131e..672d2bb6 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchiveAlbumDetailFeature.swift @@ -13,6 +13,7 @@ struct ArchiveAlbumDetailFeature { @ObservableState struct State { @Presents var albumSelection: AlbumSelectionFeature.State? + @Presents var photoImport: PhotoImportFeature.State? var selectionPurpose: PhotoSelectionPurpose? var photos: IdentifiedArrayOf = [] @@ -77,6 +78,11 @@ struct ArchiveAlbumDetailFeature { case onTapExecuteDeleteAlbum(option: ArchiveAlbumDeleteOption) case deleteAlbumResponse(Result) + // 사진 가져오기 액션 + case onTapImportPhotos + case photoImport(PresentationAction) + case importPhotosResponse(Result) + case fetchPhotos case photoListResponse(Result<[PhotoEntity], Error>) case loadMorePhotos @@ -338,9 +344,43 @@ struct ArchiveAlbumDetailFeature { case .deleteAlbumResponse(.failure): return .send(.delegate(.showToast(NekiToastItem("앨범을 삭제하지 못했어요", style: .error)))) + case .onTapImportPhotos: + state.showDropDownMenu = false + state.photoImport = PhotoImportFeature.State() + return .none + + case let .photoImport(.presented(.delegate(delegateAction))): + switch delegateAction { + case let .didImportPhotos(photoIDs): + state.photoImport = nil + state.isLoading = true + + return .run { send in + // TODO: 선택한 사진들을 현재 앨범으로 복제하는 API 호출 + try? await Task.sleep(for: .seconds(1)) // 임시 딜레이 + await send(.importPhotosResponse(.success(()))) + } + + case .didTapCancel: + state.photoImport = nil + return .none + } + + case .importPhotosResponse(.success): + state.isLoading = false + return .merge( + .send(.delegate(.showToast(NekiToastItem("사진을 앨범에 가져왔어요", style: .success)))), + .send(.fetchPhotos) + ) + + case .importPhotosResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("사진을 가져오지 못했어요", style: .error)))) + default: return .none } } .ifLet(\.$albumSelection, action: \.albumSelection) { AlbumSelectionFeature() } + .ifLet(\.$photoImport, action: \.photoImport) { PhotoImportFeature() } } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift new file mode 100644 index 00000000..970d9ae1 --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift @@ -0,0 +1,164 @@ +// +// PhotoImportFeature.swift +// Neki-iOS +// +// Created by OneTen on 4/3/26. +// + +import ComposableArchitecture +import Foundation + +@Reducer +struct PhotoImportFeature { + @ObservableState + struct State { + var albums: IdentifiedArrayOf = [] + var selectedAlbum: AlbumItem? = nil + + var photos: IdentifiedArrayOf = [] + var selectedIDs: Set = [] + + var isDropdownOpen: Bool = false + var isFetchingPhotos: Bool = false + var isFetchingAlbums: Bool = false + + var uploadCount: Int { selectedIDs.count } + var isUploadEnabled: Bool { uploadCount > 0 } + } + + enum Action: BindableAction { + case binding(BindingAction) + + case onAppear + case fetchAlbumsResponse(Result<[AlbumItem], Error>) + case fetchFavoriteAlbumResponse(Result) + case fetchPhotos + case fetchPhotosResponse(Result<[PhotoEntity], Error>) + + case toggleDropdown + case closeDropdown + case selectAlbum(AlbumItem?) + case toggleSelection(Int) + + case tapClose + case tapUpload + + case delegate(DelegateAction) + enum DelegateAction { + case didImportPhotos(photoIDs: [Int]) + case didTapCancel + } + } + + @Dependency(\.archiveClient) var archiveClient + + var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .onAppear: + state.isFetchingAlbums = true + return .merge( + .run { send in + do { + let entity = try await archiveClient.getFavoriteAlbumInfo() + let favoriteAlbum = AlbumItem(id: -1, title: "즐겨찾기", count: entity.totalCount, coverImageURL: URL(string: entity.latestImageURL), isFavorite: true) + await send(.fetchFavoriteAlbumResponse(.success(favoriteAlbum))) + } catch { + await send(.fetchFavoriteAlbumResponse(.failure(error))) + } + }, + .run { send in + await send(.fetchAlbumsResponse(Result { + let entities = try await archiveClient.getAlbumList() + return entities.map { AlbumItem(id: $0.id, title: $0.name, count: $0.photoCount, coverImageURL: URL(string: $0.coverImageURLString), isFavorite: false) } + })) + }, + .send(.fetchPhotos) + ) + + case let .fetchFavoriteAlbumResponse(.success(album)): + state.albums.removeAll(where: { $0.isFavorite }) + state.albums.insert(album, at: 0) + return .none + + case .fetchFavoriteAlbumResponse(.failure): + return .none + + case let .fetchAlbumsResponse(.success(fetchedAlbums)): + state.isFetchingAlbums = false + let favorite = state.albums.first(where: { $0.isFavorite }) + var newAlbums: [AlbumItem] = [] + if let fav = favorite { newAlbums.append(fav) } + newAlbums.append(contentsOf: fetchedAlbums) + + state.albums = IdentifiedArray(uniqueElements: newAlbums) + return .none + + case .fetchAlbumsResponse(.failure): + state.isFetchingAlbums = false + return .none + + case .fetchPhotos: + 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(100, sortOrder) })) + } else { + await send(.fetchPhotosResponse(Result { try await archiveClient.fetchPhotoList(folderId: targetFolderId, size: 100, sortOrder: sortOrder) })) + } + } + + 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.selectedAlbum = album + state.isDropdownOpen = false + state.photos.removeAll() + return .send(.fetchPhotos) + + case let .toggleSelection(id): + if state.selectedIDs.contains(id) { + state.selectedIDs.remove(id) + } else { + state.selectedIDs.insert(id) + } + return .none + + case .tapClose: + return .send(.delegate(.didTapCancel)) + + case .tapUpload: + guard state.isUploadEnabled else { return .none } + let ids = Array(state.selectedIDs) + return .send(.delegate(.didImportPhotos(photoIDs: ids))) + + case .binding, .delegate: + return .none + } + } + } +} diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index 1872ea62..aa75338b 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -60,6 +60,11 @@ struct ArchiveAlbumDetailView: View { AlbumSelectionView(store: selectionStore) } } + .sheet(item: $store.scope(state: \.photoImport, action: \.photoImport)) { importStore in + PhotoImportView(store: importStore) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } // 사진 삭제 시트 .sheet(isPresented: $deleteAlbumSheetPresented) { ArchiveDeleteSheet( @@ -189,7 +194,7 @@ private extension ArchiveAlbumDetailView { .contentShape(Rectangle()) Button { - // 사진 가져오기 액션 연결 + store.send(.onTapImportPhotos) } label: { Text("사진 가져오기") .nekiFont(.body16Medium) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift new file mode 100644 index 00000000..aaf7e88f --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift @@ -0,0 +1,180 @@ +// +// PhotoImportView.swift +// Neki-iOS +// +// Created by OneTen on 4/3/26. +// + +import SwiftUI +import ComposableArchitecture +import Kingfisher + +struct PhotoImportView: View { + @Bindable var store: StoreOf + + let columns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 3) + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + header + .padding(.top, 16) + + if store.isFetchingPhotos && store.photos.isEmpty { + LoadingView(message: "사진을 불러오는 중이에요") + } else if store.photos.isEmpty { + Spacer() + ArchiveEmptyView(description: "아직 등록된 사진이 없어요") + Spacer() + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: 2) { + ForEach(store.photos) { item in + imageCell(for: item) + } + } + .padding(.vertical, 12) + .padding(.horizontal, 20) + } + } + + Spacer() + + Button { + store.send(.tapUpload) + } label: { + Text("\(store.uploadCount)장 업로드") + } + .buttonStyle(.nekiCTA(.primary)) + .disabled(!store.isUploadEnabled) + .padding(.horizontal, 20) + } + + if store.isDropdownOpen { + dropDownMenu + .padding(.top, 48) + .padding(.leading, 20) + } + + } + .background(Color.white.ignoresSafeArea()) + .navigationBarHidden(true) + .task { + await store.send(.onAppear).finish() + } + } +} + +extension PhotoImportView { + private var header: some View { + ZStack(alignment: .center) { + HStack(alignment: .center) { + Button { + store.send(.toggleDropdown) + } label: { + HStack(spacing: 4) { + Text(store.selectedAlbum?.title ?? "전체 사진") + .nekiFont(.title20SemiBold) + .foregroundStyle(.gray900) + + Image(.iconChevronDown) + .renderingMode(.template) + .foregroundStyle(.gray500) + .rotationEffect(.degrees(store.isDropdownOpen ? 180 : 0)) + } + } + + Spacer() + + Button { + store.send(.tapClose) + } label: { + Image(.iconXmarkBlack) + } + } + .padding(.horizontal, 20) + } + .frame(height: 54) + } + + private var dropDownMenu: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + // 전체 사진 + Button { + store.send(.selectAlbum(nil)) + } label: { + HStack { + Text("전체 사진") + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + Spacer() + } + } + .frame(width: 160, height: 34, alignment: .leading) + + // 앨범 목록 + ForEach(store.albums) { album in + Button { + store.send(.selectAlbum(album)) + } label: { + HStack { + Text(album.title) + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + .lineLimit(1) + Text("\(album.count)") + .nekiFont(.caption12Medium) + .foregroundStyle(.gray300) + Spacer() + } + } + .frame(width: 160, height: 34, alignment: .leading) + + } + } + .padding(.leading, 12) + .padding(.vertical, 8) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2.5) + .frame(width: 160) + + Spacer() + } + } + + @ViewBuilder + private func imageCell(for item: ArchiveImageItem) -> some View { + let isSelected = store.selectedIDs.contains(item.id) + + ZStack(alignment: .topTrailing) { + KFImage(item.imageURL) + .resizable() + .placeholder { Color.gray.opacity(0.1) } + .aspectRatio(1, contentMode: .fit) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 145, maxHeight: .infinity) + .clipped() + .overlay( + Rectangle() + .strokeBorder(isSelected ? Color.primary400 : Color.clear, lineWidth: 2) + ) + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(isSelected ? .primary400 : .white) + .background( + Circle() + .fill(isSelected ? .white : .black.opacity(0.2)) + .frame(width: 24, height: 24) + ) + .padding(6) + } + .aspectRatio(1, contentMode: .fit) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.toggleSelection(item.id)) + } + } +} From 1b7c581af3976ca163c10b24b5332b8715e5e6fd Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 02:08:40 +0900 Subject: [PATCH 12/17] =?UTF-8?q?[Feat]=20#202=20-=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EB=90=9C=20=EC=82=AC=EC=A7=84=EB=93=A4=20=EB=B3=B4?= =?UTF-8?q?=EC=97=AC=EC=A3=BC=EB=8A=94=20=EC=8B=9C=ED=8A=B8=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Feature/PhotoImportFeature.swift | 10 +++++-- .../Sources/View/PhotoImportView.swift | 27 ++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift index 970d9ae1..9895371f 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift @@ -32,7 +32,9 @@ struct PhotoImportFeature { case onAppear case fetchAlbumsResponse(Result<[AlbumItem], Error>) case fetchFavoriteAlbumResponse(Result) + case fetchPhotos + case loadMorePhotos case fetchPhotosResponse(Result<[PhotoEntity], Error>) case toggleDropdown @@ -101,18 +103,22 @@ struct PhotoImportFeature { return .none 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(100, sortOrder) })) + await send(.fetchPhotosResponse(Result { try await archiveClient.fetchFavoritePhotoList(20, sortOrder) })) } else { - await send(.fetchPhotosResponse(Result { try await archiveClient.fetchPhotoList(folderId: targetFolderId, size: 100, sortOrder: sortOrder) })) + 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 diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift index aaf7e88f..e9ece3ea 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift @@ -28,13 +28,28 @@ struct PhotoImportView: View { Spacer() } else { ScrollView { - LazyVGrid(columns: columns, spacing: 2) { - ForEach(store.photos) { item in - imageCell(for: item) + VStack(spacing: 0) { + LazyVGrid(columns: columns, spacing: 2) { + ForEach(store.photos) { item in + imageCell(for: item) + .onAppear { + if item == store.photos.last { + store.send(.loadMorePhotos) + } + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 20) + + if store.isFetchingPhotos && !store.photos.isEmpty { + ProgressView() + .padding(.vertical, 20) + .padding(.bottom, 100) + } else { + Spacer().frame(height: 100) } } - .padding(.vertical, 12) - .padding(.horizontal, 20) } } @@ -152,7 +167,7 @@ extension PhotoImportView { KFImage(item.imageURL) .resizable() .placeholder { Color.gray.opacity(0.1) } - .aspectRatio(1, contentMode: .fit) + .aspectRatio(1, contentMode: .fill) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 145, maxHeight: .infinity) .clipped() .overlay( From e6abf7e9169955e15c61c2b735339c674f4ca2fd Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 02:28:38 +0900 Subject: [PATCH 13/17] =?UTF-8?q?[Feat]=20#202=20-=20=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=A8=EB=B2=94=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EB=90=9C=20=EC=82=AC=EC=A7=84=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Feature/PhotoImportFeature.swift | 1 + .../Sources/View/PhotoImportView.swift | 39 +++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift index 9895371f..76bcc20c 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift @@ -141,6 +141,7 @@ struct PhotoImportFeature { return .none case let .selectAlbum(album): + state.selectedIDs.removeAll() // TODO: - 현재는 앨범 변경 시 선택된 사진 해제. 피그마에 문의 남김. 추후 수정될 여지 있음 state.selectedAlbum = album state.isDropdownOpen = false state.photos.removeAll() diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift index e9ece3ea..bf2c6b38 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift @@ -16,12 +16,15 @@ struct PhotoImportView: View { var body: some View { ZStack(alignment: .top) { + VStack(spacing: 0) { header .padding(.top, 16) if store.isFetchingPhotos && store.photos.isEmpty { - LoadingView(message: "사진을 불러오는 중이에요") + Spacer() + ProgressView() + Spacer() } else if store.photos.isEmpty { Spacer() ArchiveEmptyView(description: "아직 등록된 사진이 없어요") @@ -45,24 +48,38 @@ struct PhotoImportView: View { if store.isFetchingPhotos && !store.photos.isEmpty { ProgressView() .padding(.vertical, 20) - .padding(.bottom, 100) + .padding(.bottom, 120) } else { - Spacer().frame(height: 100) + Spacer().frame(height: 120) } } } } - + } + + VStack(spacing: 0) { Spacer() - Button { - store.send(.tapUpload) - } label: { - Text("\(store.uploadCount)장 업로드") + LinearGradient( + gradient: Gradient(colors: [.clear, .white.opacity(0.5)]), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 48) + + VStack(spacing: 0) { + Button { + store.send(.tapUpload) + } label: { + Text("\(store.uploadCount)장 업로드") + } + .buttonStyle(.nekiCTA(.primary)) + .disabled(!store.isUploadEnabled) + .padding(.horizontal, 20) + .padding(.bottom, 28) + .padding(.top, 4) // MARK: - 임의로 넣었음. 너무 딱 붙으니까 이상해서 } - .buttonStyle(.nekiCTA(.primary)) - .disabled(!store.isUploadEnabled) - .padding(.horizontal, 20) + .background(.white) } if store.isDropdownOpen { From f35b9538981dd7a2f498aab86ec3b8c6d6cc925f Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 02:35:58 +0900 Subject: [PATCH 14/17] =?UTF-8?q?[Chore]=20#202-=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=ED=85=8C=EB=91=90=EB=A6=AC=20=EB=91=A5=EA=B8=80=EA=B8=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/View/ArchiveAlbumDetailView.swift | 2 + .../Sources/View/PhotoImportView.swift | 50 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index aa75338b..111e6d6e 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -60,10 +60,12 @@ struct ArchiveAlbumDetailView: View { AlbumSelectionView(store: selectionStore) } } + // 사진 가져오기 시트 .sheet(item: $store.scope(state: \.photoImport, action: \.photoImport)) { importStore in PhotoImportView(store: importStore) .presentationDetents([.large]) .presentationDragIndicator(.visible) + .presentationCornerRadius(20) } // 사진 삭제 시트 .sheet(isPresented: $deleteAlbumSheetPresented) { diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift index bf2c6b38..19825e74 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift @@ -19,7 +19,7 @@ struct PhotoImportView: View { VStack(spacing: 0) { header - .padding(.top, 16) + .padding(.top, 40) if store.isFetchingPhotos && store.photos.isEmpty { Spacer() @@ -84,7 +84,7 @@ struct PhotoImportView: View { if store.isDropdownOpen { dropDownMenu - .padding(.top, 48) + .padding(.top, 91) .padding(.leading, 20) } @@ -99,34 +99,32 @@ struct PhotoImportView: View { extension PhotoImportView { private var header: some View { - ZStack(alignment: .center) { - HStack(alignment: .center) { - Button { - store.send(.toggleDropdown) - } label: { - HStack(spacing: 4) { - Text(store.selectedAlbum?.title ?? "전체 사진") - .nekiFont(.title20SemiBold) - .foregroundStyle(.gray900) - - Image(.iconChevronDown) - .renderingMode(.template) - .foregroundStyle(.gray500) - .rotationEffect(.degrees(store.isDropdownOpen ? 180 : 0)) - } - } - - Spacer() - - Button { - store.send(.tapClose) - } label: { - Image(.iconXmarkBlack) + HStack(alignment: .center) { + Button { + store.send(.toggleDropdown) + } label: { + HStack(spacing: 4) { + Text(store.selectedAlbum?.title ?? "전체 사진") + .nekiFont(.title20SemiBold) + .foregroundStyle(.gray900) + + Image(.iconChevronDown) + .renderingMode(.template) + .foregroundStyle(.gray500) + .rotationEffect(.degrees(store.isDropdownOpen ? 180 : 0)) } } - .padding(.horizontal, 20) + + Spacer() + + Button { + store.send(.tapClose) + } label: { + Image(.iconXmarkBlack) + } } .frame(height: 54) + .padding(.horizontal, 20) } private var dropDownMenu: some View { From a0cefe0edd3c2a462e37be10aaded40231f24007 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 02:45:34 +0900 Subject: [PATCH 15/17] =?UTF-8?q?[Feat]=20#202=20-=20=EC=95=A8=EB=B2=94=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EC=9E=90=EC=97=B0=EC=8A=A4?= =?UTF-8?q?=EB=9F=AC=EC=9A=B4=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/View/PhotoImportView.swift | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift index 19825e74..dc09ab61 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift @@ -21,40 +21,48 @@ struct PhotoImportView: View { header .padding(.top, 40) - if store.isFetchingPhotos && store.photos.isEmpty { - Spacer() - ProgressView() - Spacer() - } else if store.photos.isEmpty { - Spacer() - ArchiveEmptyView(description: "아직 등록된 사진이 없어요") - Spacer() - } else { - ScrollView { - VStack(spacing: 0) { - LazyVGrid(columns: columns, spacing: 2) { - ForEach(store.photos) { item in - imageCell(for: item) - .onAppear { - if item == store.photos.last { - store.send(.loadMorePhotos) + ZStack { + if store.isFetchingPhotos && store.photos.isEmpty { + VStack { + Spacer() + ProgressView() + Spacer() + } + } else if store.photos.isEmpty { + VStack { + Spacer() + ArchiveEmptyView(description: "아직 등록된 사진이 없어요") + Spacer() + } + } else { + ScrollView { + VStack(spacing: 0) { + LazyVGrid(columns: columns, spacing: 2) { + ForEach(store.photos) { item in + imageCell(for: item) + .onAppear { + if item == store.photos.last { + store.send(.loadMorePhotos) + } } - } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 20) + + if store.isFetchingPhotos && !store.photos.isEmpty { + ProgressView() + .padding(.vertical, 20) + .padding(.bottom, 120) + } else { + Spacer().frame(height: 120) } - } - .padding(.vertical, 12) - .padding(.horizontal, 20) - - if store.isFetchingPhotos && !store.photos.isEmpty { - ProgressView() - .padding(.vertical, 20) - .padding(.bottom, 120) - } else { - Spacer().frame(height: 120) } } } } + .animation(.easeInOut(duration: 0.3), value: store.isFetchingPhotos) + .animation(.easeInOut(duration: 0.3), value: store.photos) } VStack(spacing: 0) { From b9646dcfd72757142021a68dbcfcda69e0674804 Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 02:48:44 +0900 Subject: [PATCH 16/17] =?UTF-8?q?[Feat]=20#202=20-=20=EC=A0=84=EB=B0=98?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=9E=90=EC=97=B0=EC=8A=A4?= =?UTF-8?q?=EB=9F=AC=EC=9A=B4=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Sources/View/ArchiveAlbumDetailView.swift | 1 + .../Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swift | 1 + .../Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift | 1 + .../Presentation/Sources/View/ArchiveFavoriteAlbumView.swift | 1 + 4 files changed, 4 insertions(+) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index 111e6d6e..4efebe41 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -54,6 +54,7 @@ struct ArchiveAlbumDetailView: View { LoadingView(message: "작업을 수행하고 있어요.") } } + .animation(.easeInOut(duration: 0.3), value: store.photos) .task { await store.send(.onAppear).finish() } .fullScreenCover(item: $store.scope(state: \.albumSelection, action: \.albumSelection)) { selectionStore in NavigationStack { diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swift index df52e95b..85ffe2b6 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllAlbumsView.swift @@ -59,6 +59,7 @@ struct ArchiveAllAlbumsView: View { } } + .animation(.easeInOut(duration: 0.3), value: store.albums) .task { await store.send(.onAppear).finish() } .sheet(isPresented: $addAlbumSheetPresented) { ArchiveAlbumInputSheet( diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift index e087d7a4..a6e72f90 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift @@ -49,6 +49,7 @@ struct ArchiveAllPhotosView: View { } } + .animation(.easeInOut(duration: 0.3), value: store.photos) .nekiToolbar( left: { NekiToolBar.back(action: { store.send(.onTapBackButton) }) }, center: { NekiToolBar.textCenter("모든 사진") }, diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift index e7743cf8..b0d06686 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift @@ -51,6 +51,7 @@ struct ArchiveFavoriteAlbumView: View { } } + .animation(.easeInOut(duration: 0.3), value: store.photos) .task { await store.send(.onAppear).finish() } .fullScreenCover(item: $store.scope(state: \.albumSelection, action: \.albumSelection)) { selectionStore in NavigationStack { From b5383b34499ed447aa0c60a980039198f1848d8d Mon Sep 17 00:00:00 2001 From: OneTen19 Date: Fri, 3 Apr 2026 03:03:25 +0900 Subject: [PATCH 17/17] =?UTF-8?q?[Feat]=20#202=20-=20=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EA=B7=B8=EB=9E=A8=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/ArchivePhotoDetailFeature.swift | 98 ++++++++++++++++--- .../Sources/View/ArchiveAlbumDetailView.swift | 2 +- .../Sources/View/ArchiveAllPhotosView.swift | 2 +- .../View/ArchiveFavoriteAlbumView.swift | 2 +- .../Sources/View/ArchivePhotoDetailView.swift | 11 +++ Neki-iOS/Info.plist | 3 + 6 files changed, 99 insertions(+), 19 deletions(-) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift index d790e759..38ac4ad8 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift @@ -63,6 +63,9 @@ struct ArchivePhotoDetailFeature { case toggleDropDownMenu case closeDropDownMenu + case onTapShareToInstagramStory + case instagramImageFetchResponse(Result) + case delegate(Delegate) enum Delegate { case showToast(NekiToastItem) @@ -175,24 +178,24 @@ struct ArchivePhotoDetailFeature { switch delegateAction { case let .didSelectAlbum(albumId): -// let photoId = state.currentItemID -// state.albumSelection = nil -// state.isLoading = true -// + // let photoId = state.currentItemID + // state.albumSelection = nil + // state.isLoading = true + // // TODO: - 어떤 식으로 업로드 할 건지 서버측과 논의 필요함 // 그냥 킹피셔로 데이터 추출해서 새로운 파일로 업로드하는 것도 가능하긴 한데, 흠 -// return .run { send in -// await send(.addToAlbumResponse(Result { -// let isFavorite = albumId == -1 -// let targetFolderId = isFavorite ? nil : albumId -// -// try await archiveClient.registerPhotos( -// folderId: targetFolderId, -// uploads: [(mediaID: photoId, memo: String?.none, uploadMethod: PhotoUploadMethod.direct)], -// favorite: isFavorite -// ) -// })) -// } + // return .run { send in + // await send(.addToAlbumResponse(Result { + // let isFavorite = albumId == -1 + // let targetFolderId = isFavorite ? nil : albumId + // + // try await archiveClient.registerPhotos( + // folderId: targetFolderId, + // uploads: [(mediaID: photoId, memo: String?.none, uploadMethod: PhotoUploadMethod.direct)], + // favorite: isFavorite + // ) + // })) + // } return .none case .didTapCancel: @@ -277,6 +280,69 @@ struct ArchivePhotoDetailFeature { case .deletePhotoResponse(.failure): return .send(.delegate(.showToast(NekiToastItem("사진을 삭제하지 못했어요", style: .error)))) + case .onTapShareToInstagramStory: + state.showDropDownMenu = false + guard let url = state.currentItem?.imageURL else { return .none } + + state.isLoading = true + + return .run { send in + do { + let image = try await withCheckedThrowingContinuation { continuation in + KingfisherManager.shared.retrieveImage( + with: url, + options: [.cacheOriginalImage] + ) { result in + switch result { + case .success(let value): + continuation.resume(returning: value.image) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + await send(.instagramImageFetchResponse(.success(image))) + } catch { + await send(.instagramImageFetchResponse(.failure(error))) + } + } + + case let .instagramImageFetchResponse(.success(image)): + state.isLoading = false + return .run { send in + // 이미지를 클립보드에 담고 인스타그램 앱 열기 (MainActor에서 실행) + let isShared = await MainActor.run { () -> Bool in + guard let urlScheme = URL(string: "instagram-stories://share?source_application=Neki") else { return false } + + // 인스타그램 앱이 설치되어 있는지 확인 + if UIApplication.shared.canOpenURL(urlScheme) { + if let imageData = image.pngData() { + // 인스타 스토리 배경으로 이미지 전달 + let pasteboardItems: [[String: Any]] = [ + ["com.instagram.sharedSticker.backgroundImage": imageData] + ] + let pasteboardOptions = [ + UIPasteboard.OptionsKey.expirationDate: Date().addingTimeInterval(60 * 5) + ] + + UIPasteboard.general.setItems(pasteboardItems, options: pasteboardOptions) + UIApplication.shared.open(urlScheme, options: [:], completionHandler: nil) + return true + } + } + return false + } + + // 설치되어 있지 않거나 실패한 경우 토스트 + if !isShared { + await send(.delegate(.showToast(NekiToastItem("인스타그램 앱이 설치되어 있지 않아요", style: .error)))) + } + } + + case .instagramImageFetchResponse(.failure): + state.isLoading = false + return .send(.delegate(.showToast(NekiToastItem("이미지를 불러오지 못했어요", style: .error)))) + default: return .none } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift index 4efebe41..a1128284 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAlbumDetailView.swift @@ -51,7 +51,7 @@ struct ArchiveAlbumDetailView: View { } if store.isLoading { - LoadingView(message: "작업을 수행하고 있어요.") + LoadingView(message: "요청을 처리하고 있어요.") } } .animation(.easeInOut(duration: 0.3), value: store.photos) diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift index a6e72f90..35860904 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift @@ -45,7 +45,7 @@ struct ArchiveAllPhotosView: View { } if store.isLoading { - LoadingView(message: "작업을 수행하고 있어요.") + LoadingView(message: "요청을 처리하고 있어요.") } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift index b0d06686..d078d365 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift @@ -47,7 +47,7 @@ struct ArchiveFavoriteAlbumView: View { } if store.isLoading { - LoadingView(message: "작업을 수행하고 있어요.") + LoadingView(message: "요청을 처리하고 있어요.") } } diff --git a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift index bca08fe6..d9f73e8f 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchivePhotoDetailView.swift @@ -159,6 +159,17 @@ extension ArchivePhotoDetailView { .frame(width: 158, height: 34, alignment: .leading) .padding(.leading, 12) .contentShape(Rectangle()) + + Button { + store.send(.onTapShareToInstagramStory) + } label: { + Text("인스타 스토리 공유") + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + } + .frame(width: 158, height: 34, alignment: .leading) + .padding(.leading, 12) + .contentShape(Rectangle()) #endif } diff --git a/Neki-iOS/Info.plist b/Neki-iOS/Info.plist index 00d25b48..0b070d3a 100644 --- a/Neki-iOS/Info.plist +++ b/Neki-iOS/Info.plist @@ -2,6 +2,8 @@ + CFBundleIdentifier + QR_WEBHOOK_URL $(QR_WEBHOOK_URL) BASE_URL @@ -31,6 +33,7 @@ $(KAKAO_LOGIN_NATIVE_APP_KEY) LSApplicationQueriesSchemes + instagram-stories nmap kakaomap comgooglemaps