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..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,55 +7,141 @@ import SwiftUI +public enum PhotoSelectionPurpose { + case duplicate + case move +} + +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/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..0ff6abfe --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/AlbumSelectionFeature.swift @@ -0,0 +1,166 @@ +// +// AlbumSelectionFeature.swift +// Neki-iOS +// +// 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 + } + } + } +} 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..672d2bb6 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,15 @@ import Foundation @Reducer struct ArchiveAlbumDetailFeature { - @ObservableState struct State { - var photos: IdentifiedArrayOf = [] + @Presents var albumSelection: AlbumSelectionFeature.State? + @Presents var photoImport: PhotoImportFeature.State? + var selectionPurpose: PhotoSelectionPurpose? + var photos: IdentifiedArrayOf = [] let album: AlbumItem - var showDropDownMenu: Bool = false - var selectedIDs: Set = [] var newAlbumTitle: String = "" @@ -29,16 +29,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,15 +57,32 @@ 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) case deletePhotosResponse(Result) + + // 앨범 삭제 액션 + case onTapExecuteDeleteAlbum(option: ArchiveAlbumDeleteOption) + case deleteAlbumResponse(Result) + + // 사진 가져오기 액션 + case onTapImportPhotos + case photoImport(PresentationAction) + case importPhotosResponse(Result) + case fetchPhotos case photoListResponse(Result<[PhotoEntity], Error>) case loadMorePhotos @@ -91,17 +102,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 +120,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 +130,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 +148,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 +167,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 +176,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 +188,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 +205,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 +219,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 +302,85 @@ 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: + 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)))) + + 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/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/Feature/ArchivePhotoDetailFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/ArchivePhotoDetailFeature.swift index 61d839d1..38ac4ad8 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,24 @@ 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 onTapShareToInstagramStory + case instagramImageFetchResponse(Result) + case delegate(Delegate) enum Delegate { case showToast(NekiToastItem) @@ -84,7 +82,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 +106,6 @@ struct ArchivePhotoDetailFeature { state.isMemoExpanded = isExpanded return .none - // MARK: - 메모 편집 모드 로직 case .startMemoEditing: state.isMemoEditing = true state.isMemoVisible = true @@ -110,38 +119,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 +167,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 +248,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,20 +275,79 @@ struct ArchivePhotoDetailFeature { } else if let last = state.photos.last { state.currentItemID = last.id } - return .send(.delegate(.showToast(NekiToastItem("사진을 삭제했어요", style: .success)))) 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 } } - .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/Feature/PhotoImportFeature.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift new file mode 100644 index 00000000..76bcc20c --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/Feature/PhotoImportFeature.swift @@ -0,0 +1,171 @@ +// +// 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 loadMorePhotos + 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: + guard !state.isFetchingPhotos else { return .none } + state.isFetchingPhotos = true + let targetFolderId = state.selectedAlbum?.id == -1 ? nil : state.selectedAlbum?.id + let sortOrder = "DESC" + + return .run { [id = state.selectedAlbum?.id] send in + if id == -1 { + await send(.fetchPhotosResponse(Result { try await archiveClient.fetchFavoritePhotoList(20, sortOrder) })) + } else { + await send(.fetchPhotosResponse(Result { try await archiveClient.fetchPhotoList(folderId: targetFolderId, size: 20, sortOrder: sortOrder) })) + } + } + + case .loadMorePhotos: + return .send(.fetchPhotos) + + case let .fetchPhotosResponse(.success(entities)): + state.isFetchingPhotos = false + let currentAlbumId = state.selectedAlbum?.id + let newItems = entities.map { entity in + ArchiveImageItem(id: entity.photoID, imageURLString: entity.imageURL, isFavorite: entity.isfavorite, date: entity.createdAt.toISO8601Date(), folderId: currentAlbumId, memo: entity.memo ?? "", width: entity.width, height: entity.height) + } + state.photos = IdentifiedArray(uniqueElements: newItems) + return .none + + case .fetchPhotosResponse(.failure): + state.isFetchingPhotos = false + return .none + + case .toggleDropdown: + state.isDropdownOpen.toggle() + return .none + + case .closeDropdown: + state.isDropdownOpen = false + return .none + + case let .selectAlbum(album): + state.selectedIDs.removeAll() // TODO: - 현재는 앨범 변경 시 선택된 사진 해제. 피그마에 문의 남김. 추후 수정될 여지 있음 + state.selectedAlbum = album + state.isDropdownOpen = false + state.photos.removeAll() + return .send(.fetchPhotos) + + 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/AlbumSelectionView.swift b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift new file mode 100644 index 00000000..063a7764 --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/AlbumSelectionView.swift @@ -0,0 +1,131 @@ +// +// AlbumSelectionView.swift +// Neki-iOS +// +// Created by OneTen on 4/2/26. +// + +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) + } +} 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..a1128284 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) { @@ -39,18 +40,35 @@ struct ArchiveAlbumDetailView: View { VStack { Spacer() ArchiveImageFooter( + style: .selection, isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, - onDelete: { deleteAlbumSheetPresented = true } + onDelete: { deleteAlbumSheetPresented = true }, + onDuplicate: { store.send(.onTapDuplicateButton) }, + onMove: { store.send(.onTapMoveButton) } ) } } if store.isLoading { - LoadingView(message: "사진을 업로드하고 있어요.") + 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 { + 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) { ArchiveDeleteSheet( initialOption: .fromAlbumOnly, @@ -68,6 +86,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, @@ -149,7 +186,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) @@ -158,7 +195,18 @@ private extension ArchiveAlbumDetailView { .frame(width: 120, height: 34, alignment: .leading) .padding(.leading, 12) .contentShape(Rectangle()) - + + Button { + store.send(.onTapImportPhotos) + } label: { + Text("사진 가져오기") + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + } + .frame(width: 120, height: 34, alignment: .leading) + .padding(.leading, 12) + .contentShape(Rectangle()) + Button { store.send(.closeDropDownMenu) editAlbumNameSheetPresented = true @@ -170,6 +218,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) 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 9350d682..35860904 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveAllPhotosView.swift @@ -34,14 +34,22 @@ struct ArchiveAllPhotosView: View { VStack { Spacer() ArchiveImageFooter( + style: .selection, isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, - onDelete: { showDeleteAlert = true } + onDelete: { showDeleteAlert = true }, + onDuplicate: { store.send(.onTapDuplicateButton) }, + onMove: { store.send(.onTapMoveButton) } ) } } + if store.isLoading { + LoadingView(message: "요청을 처리하고 있어요.") + } + } + .animation(.easeInOut(duration: 0.3), value: store.photos) .nekiToolbar( left: { NekiToolBar.back(action: { store.send(.onTapBackButton) }) }, center: { NekiToolBar.textCenter("모든 사진") }, @@ -69,6 +77,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 f753fb36..d078d365 100644 --- a/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/ArchiveFavoriteAlbumView.swift @@ -36,19 +36,28 @@ struct ArchiveFavoriteAlbumView: View { VStack { Spacer() ArchiveImageFooter( + style: .selection, isEnabled: store.hasSelectedItems, onDownload: { store.send(.onTapDownloadButton) }, - onDelete: { showDeleteAlert = true } + onDelete: { showDeleteAlert = true }, + onDuplicate: { store.send(.onTapDuplicateButton) }, + onMove: { store.send(.onTapMoveButton) } ) } } if store.isLoading { - LoadingView(message: "사진을 업로드하고 있어요.") + 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 { + AlbumSelectionView(store: selectionStore) + } + } .nekiAlert( isPresented: $showDeleteAlert, style: .cancelable, @@ -121,7 +130,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) 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..d9f73e8f 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,64 @@ 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()) + + + // MARK: - 내부 테스트 전용 기능 + #if DEBUG + Button { + store.send(.onTapTransform) + } label: { + Text("이미지 변환") + .nekiFont(.body16Medium) + .foregroundStyle(.gray900) + } + .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 + + } + .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 +272,7 @@ extension ArchivePhotoDetailView { .nekiFont(.body16SemiBold) .foregroundStyle(.gray800) } - + Button { store.send(.doneMemoEditing) isMemoEditingFocused = false 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..dc09ab61 --- /dev/null +++ b/Neki-iOS/Features/Archive/Sources/Presentation/Sources/View/PhotoImportView.swift @@ -0,0 +1,218 @@ +// +// 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, 40) + + 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) + } + } + } + } + } + .animation(.easeInOut(duration: 0.3), value: store.isFetchingPhotos) + .animation(.easeInOut(duration: 0.3), value: store.photos) + } + + VStack(spacing: 0) { + Spacer() + + 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: - 임의로 넣었음. 너무 딱 붙으니까 이상해서 + } + .background(.white) + } + + if store.isDropdownOpen { + dropDownMenu + .padding(.top, 91) + .padding(.leading, 20) + } + + } + .background(Color.white.ignoresSafeArea()) + .navigationBarHidden(true) + .task { + await store.send(.onAppear).finish() + } + } +} + +extension PhotoImportView { + private var header: some View { + 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) + } + } + .frame(height: 54) + .padding(.horizontal, 20) + } + + 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: .fill) + .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)) + } + } +} 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) 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 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 @@ + + + + + +