From c6f406e8fb576f38a77571c14f29f34e544c4684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Tue, 8 Apr 2025 10:02:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor/#102:=20PopUpRegisterViewControlle?= =?UTF-8?q?r=20Reactor=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PopUpStoreRegisterReactor.swift | 778 +++++++- .../PopUpStoreRegisterViewController.swift | 1771 +++++------------ 2 files changed, 1236 insertions(+), 1313 deletions(-) diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift index f3acf044..7b5e4539 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift @@ -1,110 +1,846 @@ +import UIKit + +import CoreLocation + import ReactorKit import RxCocoa import RxSwift + final class PopUpStoreRegisterReactor: Reactor { + // MARK: - Properties + private let adminUseCase: AdminUseCase + private let presignedService: PreSignedService + private let isEditMode: Bool + private let editingStoreId: Int64? + + init(adminUseCase: AdminUseCase, presignedService: PreSignedService, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { + self.adminUseCase = adminUseCase + self.presignedService = presignedService + self.isEditMode = editingStore != nil + self.editingStoreId = editingStore?.id + + // 초기 상태 설정 + let initialState = State(isEditMode: self.isEditMode) + self.initialState = initialState + } + // MARK: - Action enum Action { + // 폼 데이터 업데이트 case updateName(String) case updateAddress(String) case updateLat(String) case updateLon(String) case updateDescription(String) case selectCategory(String) - case addImage(ExtendedImage) + case updateMarkerTitle(String) + case updateMarkerSnippet(String) + + // 날짜/시간 관련 + case selectDateRange(start: Date, end: Date) + case selectTimeRange(start: Date, end: Date) + + // 이미지 관련 + case addImages([ExtendedImage]) case removeImage(Int) - case tapSave + case removeAllImages + case toggleMainImage(Int) + case markImageDeleted(String, Int64) + + // 네트워크 관련 + case loadStoreDetail(Int64) + case geocodeAddress(String) + case save + + // 기타 + case clearError + case dismissSuccess } // MARK: - Mutation enum Mutation { + // 폼 데이터 설정 case setName(String) case setAddress(String) case setLat(String) case setLon(String) case setDescription(String) case setCategory(String) - case addImage(ExtendedImage) + case setMarkerTitle(String) + case setMarkerSnippet(String) + + // 날짜/시간 설정 + case setDateRange(start: Date, end: Date) + case setTimeRange(start: Date, end: Date) + + // 이미지 관리 + case setImages([ExtendedImage]) + case addImages([ExtendedImage]) case removeImage(Int) + case removeAllImages + case toggleMainImage(Int) + case addDeletedImage(id: Int64, path: String) + + // 기존 스토어 데이터 설정 + case setStoreDetail(GetAdminPopUpStoreDetailResponseDTO) + case setOriginalImageIds([String: Int64]) + + // UI 상태 관리 + case setLoading(Bool) case setSaveEnabled(Bool) + case setSuccess(Bool) + case setError(String?) } // MARK: - State struct State { + // 폼 데이터 var name: String = "" var address: String = "" var lat: String = "" var lon: String = "" var description: String = "" var category: String = "" + var categoryId: Int64 = 0 + var markerTitle: String = "마커 제목" + var markerSnippet: String = "마커 설명" + + // 날짜 및 시간 + var selectedStartDate: Date? + var selectedEndDate: Date? + var selectedStartTime: Date? + var selectedEndTime: Date? + + // 이미지 관련 var images: [ExtendedImage] = [] + var originalImageIds: [String: Int64] = [:] + var deletedImageIds: [Int64] = [] + var deletedImagePaths: [String] = [] + + // UI 상태 + var isLoading: Bool = false var isSaveEnabled: Bool = false + var isSuccess: Bool = false + var errorMessage: String? + + // 모드 구분 + var isEditMode: Bool + + init(isEditMode: Bool = false) { + self.isEditMode = isEditMode + } } - let initialState = State() + let initialState: State // MARK: - Mutate func mutate(action: Action) -> Observable { switch action { + // 폼 데이터 업데이트 액션 case let .updateName(name): return .just(.setName(name)) + case let .updateAddress(address): return .just(.setAddress(address)) + case let .updateLat(lat): return .just(.setLat(lat)) + case let .updateLon(lon): return .just(.setLon(lon)) + case let .updateDescription(desc): return .just(.setDescription(desc)) + case let .selectCategory(category): return .just(.setCategory(category)) - case let .addImage(image): - return .just(.addImage(image)) + + case let .updateMarkerTitle(title): + return .just(.setMarkerTitle(title)) + + case let .updateMarkerSnippet(snippet): + return .just(.setMarkerSnippet(snippet)) + + // 날짜/시간 관련 액션 + case let .selectDateRange(start, end): + return .just(.setDateRange(start: start, end: end)) + + case let .selectTimeRange(start, end): + return .just(.setTimeRange(start: start, end: end)) + + // 이미지 관련 액션 + case let .addImages(newImages): + return .just(.addImages(newImages)) + case let .removeImage(index): return .just(.removeImage(index)) - case .tapSave: - // API 호출 등 저장 로직은 여기서 처리하거나 별도 Service로 위임합니다. - // 이 예제에서는 저장 전 폼 유효성 검증만 진행합니다. - return .empty() + + case .removeAllImages: + return .just(.removeAllImages) + + case let .toggleMainImage(index): + return .just(.toggleMainImage(index)) + + case let .markImageDeleted(path, id): + return .just(.addDeletedImage(id: id, path: path)) + + // 주소 지오코딩 + case let .geocodeAddress(address): + return geocodeAddress(address: address) + .flatMap { location -> Observable in + guard let location = location else { + return .just(.setError("주소를 찾을 수 없습니다.")) + } + + let latMutation = Mutation.setLat(String(format: "%.6f", location.coordinate.latitude)) + let lonMutation = Mutation.setLon(String(format: "%.6f", location.coordinate.longitude)) + + return .concat([ + .just(latMutation), + .just(lonMutation) + ]) + } + + + + // 스토어 상세 정보 로드 (수정 모드) + case let .loadStoreDetail(storeId): + return Observable.concat([ + .just(.setLoading(true)), + loadStoreDetail(storeId: storeId) + .catch { error in .just(.setError(error.localizedDescription)) }, + .just(.setLoading(false)) + ]) + + // 저장 액션 + case .save: + return Observable.concat([ + .just(.setLoading(true)), + saveStore() + .catch { error in .just(.setError(error.localizedDescription)) }, + .just(.setLoading(false)) + ]) + + // 오류 초기화 + case .clearError: + return .just(.setError(nil)) + + // 성공 상태 초기화 + case .dismissSuccess: + return .just(.setSuccess(false)) } } + // MARK: - Transform + func transform(mutation: Observable) -> Observable { + return mutation + .map { mutation -> [Mutation] in + // 특정 mutation이 발생한 경우 상태에 따라 추가 mutation을 발생시킴 + var mutations: [Mutation] = [mutation] + + if case .setLoading(true) = mutation { + mutations.append(.setError(nil)) + } + + return mutations + } + .flatMap { Observable.from($0) } + } + // MARK: - Reduce func reduce(state: State, mutation: Mutation) -> State { var newState = state + switch mutation { + // 폼 데이터 설정 case let .setName(name): newState.name = name + case let .setAddress(address): newState.address = address + case let .setLat(lat): newState.lat = lat + case let .setLon(lon): newState.lon = lon + case let .setDescription(desc): newState.description = desc + case let .setCategory(category): newState.category = category - case let .addImage(image): - newState.images.append(image) + newState.categoryId = Int64(getCategoryId(from: category)) + + case let .setMarkerTitle(title): + newState.markerTitle = title + + case let .setMarkerSnippet(snippet): + newState.markerSnippet = snippet + + // 날짜/시간 설정 + case let .setDateRange(start, end): + newState.selectedStartDate = start + newState.selectedEndDate = end + + case let .setTimeRange(start, end): + newState.selectedStartTime = start + newState.selectedEndTime = end + + // 이미지 관련 + case let .setImages(images): + newState.images = images + + case let .addImages(newImages): + newState.images.append(contentsOf: newImages) + + // 이미지가 이제 있고 대표 이미지가 없으면 첫 번째를 대표로 설정 + if !newState.images.isEmpty && !newState.images.contains(where: { $0.isMain }) { + newState.images[0].isMain = true + } + case let .removeImage(index): if index < newState.images.count { + let wasMainImage = newState.images[index].isMain + let removedImagePath = newState.images[index].filePath + + // 기존 이미지인 경우 삭제 목록에 추가 + if let imageId = newState.originalImageIds[removedImagePath] { + if !newState.deletedImageIds.contains(imageId) { + newState.deletedImageIds.append(imageId) + newState.deletedImagePaths.append(removedImagePath) + } + } + + // 이미지 배열에서 제거 newState.images.remove(at: index) + + // 대표 이미지가 삭제되었고 다른 이미지가 있으면 첫 번째를 대표로 설정 + if wasMainImage && !newState.images.isEmpty { + newState.images[0].isMain = true + } + } + + case .removeAllImages: + // 기존 이미지인 경우 모두 삭제 목록에 추가 + for image in newState.images { + if let imageId = newState.originalImageIds[image.filePath] { + if !newState.deletedImageIds.contains(imageId) { + newState.deletedImageIds.append(imageId) + newState.deletedImagePaths.append(image.filePath) + } + } + } + + newState.images.removeAll() + + case let .toggleMainImage(index): + if index < newState.images.count { + for i in 0.. Bool { + // 필수 필드 검사 + guard !state.name.isEmpty, + !state.address.isEmpty, + !state.lat.isEmpty, + !state.lon.isEmpty, + !state.description.isEmpty, + !state.category.isEmpty, + !state.images.isEmpty, + state.images.contains(where: { $0.isMain }), + state.selectedStartDate != nil, + state.selectedEndDate != nil else { + return false + } + + // 위도/경도 유효성 검사 + guard let latVal = Double(state.lat), + let lonVal = Double(state.lon), + latVal != 0 || lonVal != 0 else { + return false + } + + // 날짜 순서 검사 + if let startDate = state.selectedStartDate, + let endDate = state.selectedEndDate, + startDate > endDate { + return false + } + + return true + } + + // 주소 지오코딩 + private func geocodeAddress(address: String) -> Observable { + return Observable.create { observer in + let geocoder = CLGeocoder() + let fullAddress = "\(address), Korea" + + geocoder.geocodeAddressString( + fullAddress, + in: nil, + preferredLocale: Locale(identifier: "ko_KR") + ) { placemarks, error in + if let error = error { + Logger.log(message: "Geocoding error: \(error.localizedDescription)", category: .error) + observer.onNext(nil) + observer.onCompleted() + return + } + + if let location = placemarks?.first?.location { + observer.onNext(location) + } else { + observer.onNext(nil) + } + observer.onCompleted() + } + + return Disposables.create() + } + } + + // S3에서 이미지 삭제 + private func deleteImagesFromS3(_ imagePaths: [String]) { + guard !imagePaths.isEmpty else { return } + + presignedService.tryDelete(targetPaths: .init(objectKeyList: imagePaths)) + .subscribe( + onCompleted: { + Logger.log(message: "S3에서 모든 이미지 삭제 성공: \(imagePaths.count)개", category: .info) + }, + onError: { error in + Logger.log(message: "S3에서 이미지 삭제 실패: \(error.localizedDescription)", category: .error) + } + ) + .disposed(by: DisposeBag()) + } + + // 카테고리 ID 매핑 + private func getCategoryId(from title: String) -> Int { + let cleanTitle = title.replacingOccurrences(of: " ▾", with: "") + Logger.log(message: "카테고리 매핑 시작 - 타이틀: \(cleanTitle)", category: .debug) + + let categoryMap: [String: Int64] = [ + "패션": 1, + "라이프스타일": 2, + "뷰티": 3, + "음식/요리": 4, + "예술": 5, + "반려동물": 6, + "여행": 7, + "엔터테인먼트": 8, + "애니메이션": 9, + "키즈": 10, + "스포츠": 11, + "게임": 12 + ] + + if let id = categoryMap[cleanTitle] { + Logger.log(message: "카테고리 매핑 성공: \(cleanTitle) -> \(id)", category: .debug) + return Int(id) + } else { + Logger.log(message: "카테고리 매핑 실패: \(cleanTitle)에 해당하는 ID를 찾을 수 없음", category: .error) + return 1 // 기본값 + } + } + + // 날짜 형식 변환 + private func getFormattedDate(from date: Date?) -> String { + guard let date = date else { return "" } + + let formatter = DateFormatter() + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + + return formatter.string(from: date) + } + + // 날짜 및 시간 결합 + private func createDateTime(date: Date?, time: Date?) -> Date? { + guard let date = date else { return nil } + + if let time = time { + let calendar = Calendar.current + + // 날짜 부분 추출 + var components = calendar.dateComponents([.year, .month, .day], from: date) + + // 시간 부분 추출 + let timeComponents = calendar.dateComponents([.hour, .minute], from: time) + + // 날짜와 시간 결합 + components.hour = timeComponents.hour + components.minute = timeComponents.minute + components.second = 0 + components.timeZone = TimeZone(identifier: "Asia/Seoul") + + return calendar.date(from: components) + } + + return date + } + + // 날짜/시간 준비 + private func prepareDateTime(state: State) -> (startDate: String, endDate: String) { + // 시작일/시간 결합 + let startDateTime = createDateTime(date: state.selectedStartDate, time: state.selectedStartTime) + + // 종료일/시간 결합 + let endDateTime = createDateTime(date: state.selectedEndDate, time: state.selectedEndTime) + + let startDate = getFormattedDate(from: startDateTime) + let endDate = getFormattedDate(from: endDateTime) + + return (startDate: startDate, endDate: endDate) + } + + // 스토어 상세 정보 로드 + private func loadStoreDetail(storeId: Int64) -> Observable { + return adminUseCase.fetchStoreDetail(id: storeId) + .flatMap { [weak self] storeDetail -> Observable in + guard let self = self else { return .empty() } + + // 이미지 ID 매핑 초기화 및 설정 + var originalImageIds: [String: Int64] = [:] + for image in storeDetail.imageList { + originalImageIds[image.imageUrl] = image.id + } + + // 중복 및 삭제된 이미지 제외를 위한 집합 + var loadedImageUrls = Set() + let deletedIdSet = Set(self.currentState.deletedImageIds) + + // 이미지 로드 및 변환 + let mainImageUrl = storeDetail.mainImageUrl + var loadedImages: [ExtendedImage] = [] + let dispatchGroup = DispatchGroup() + + let imageObservable = Observable.create { observer in + for imageData in storeDetail.imageList { + // 중복 이미지 건너뛰기 + if loadedImageUrls.contains(imageData.imageUrl) { + continue + } + + // 삭제된 이미지 건너뛰기 + if deletedIdSet.contains(imageData.id) { + continue + } + + loadedImageUrls.insert(imageData.imageUrl) + + dispatchGroup.enter() + + if let imageURL = self.presignedService.fullImageURL(from: imageData.imageUrl) { + URLSession.shared.dataTask(with: imageURL) { data, response, error in + defer { dispatchGroup.leave() } + + if let error = error { + Logger.log(message: "이미지 로드 오류: \(error.localizedDescription)", category: .error) + return + } + + guard let data = data, + let image = UIImage(data: data) else { + Logger.log(message: "이미지 변환 실패", category: .error) + return + } + + let isMain = (imageData.imageUrl == mainImageUrl) + let extendedImage = ExtendedImage(filePath: imageData.imageUrl, image: image, isMain: isMain) + + DispatchQueue.main.async { + loadedImages.append(extendedImage) + } + }.resume() + } else { + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + if !loadedImages.isEmpty && !loadedImages.contains(where: { $0.isMain }) { + loadedImages[0].isMain = true + } + + observer.onNext(.setImages(loadedImages)) + observer.onNext(.setOriginalImageIds(originalImageIds)) + observer.onCompleted() + } + + return Disposables.create() + } + + return Observable.concat([ + .just(.setStoreDetail(storeDetail)), + imageObservable + ]) + } + } + + // 저장 액션 처리 + private func saveStore() -> Observable { + let state = self.currentState + + // 유효성 검사 + if !validateForm(state: state) { + return .just(.setError("필수 항목을 모두 입력해 주세요.")) + } + + if state.isEditMode { + return updateStore() + } else { + return createStore() + } + } + + // 이미지 업로드 + private func uploadImages() -> Observable<[String]> { + let uuid = UUID().uuidString + let updatedImages = currentState.images.enumerated().map { index, image in + let filePath = "PopUpImage/\(currentState.name)/\(uuid)/\(index).jpg" + return ExtendedImage( + filePath: filePath, + image: image.image, + isMain: image.isMain) + } + + return presignedService.tryUpload(datas: updatedImages.map { + PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) + }) + .asObservable() // Single을 Observable로 변환 + .map { _ in updatedImages.map { $0.filePath } } + } + + + // 신규 스토어 등록 + private func createStore() -> Observable { + return uploadImages() + .flatMap { [weak self] imagePaths -> Observable in + guard let self = self else { return .empty() } + + let state = self.currentState + let dates = self.prepareDateTime(state: state) + + // 메인 이미지 찾기 + let mainImage = imagePaths.first { path in + if let index = state.images.firstIndex(where: { $0.filePath == path }) { + return state.images[index].isMain + } + return false + } ?? imagePaths.first ?? "" + + let request = CreatePopUpStoreRequestDTO( + name: state.name, + categoryId: state.categoryId, + desc: state.description, + address: state.address, + startDate: dates.startDate, + endDate: dates.endDate, + mainImageUrl: mainImage, + imageUrlList: imagePaths, + latitude: Double(state.lat) ?? 0, + longitude: Double(state.lon) ?? 0, + markerTitle: state.markerTitle, + markerSnippet: state.markerSnippet, + startDateBeforeEndDate: true + ) + + return self.adminUseCase.createStore(request: request) + .map { _ in .setSuccess(true) } + } + } + + // 기존 스토어 수정 + private func updateStore() -> Observable { + // 기존에 저장된 이미지는 재업로드하지 않고 그대로 사용 + // 새로 추가된 이미지만 업로드 + let state = self.currentState + + // 새로 추가된 이미지만 필터링 + let newImages = state.images.filter { image in + return !state.originalImageIds.keys.contains(image.filePath) + } + + // 새 이미지가 없으면 바로 스토어 정보 업데이트 + if newImages.isEmpty { + return updateStoreInfo(nil) + } + + // 새 이미지 업로드 + return uploadNewImages(newImages) + .flatMap { [weak self] newImagePaths -> Observable in + guard let self = self else { return .empty() } + return self.updateStoreInfo(newImagePaths) + } + } + + // 새 이미지 업로드 + private func uploadNewImages(_ newImages: [ExtendedImage]) -> Observable<[String]> { + let uuid = UUID().uuidString + let updatedImages = newImages.enumerated().map { index, image in + let filePath = "PopUpImage/\(currentState.name)/\(uuid)/\(index).jpg" + return ExtendedImage( + filePath: filePath, + image: image.image, + isMain: image.isMain) + } + + return presignedService.tryUpload(datas: updatedImages.map { + PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) + }) + .asObservable() + .map { _ in updatedImages.map { $0.filePath } } + } + + + // 스토어 정보 업데이트 + private func updateStoreInfo(_ newImagePaths: [String]?) -> Observable { + guard let storeId = editingStoreId else { + return .just(.setError("스토어 ID가 없습니다.")) + } + + let state = self.currentState + let dates = prepareDateTime(state: state) + + // 모든 이미지 경로 (기존 이미지 + 새 이미지) + var allPaths: [String] = [] + + // 삭제되지 않은 기존 이미지 경로 추가 + let deletedIdSet = Set(state.deletedImageIds) + for (path, id) in state.originalImageIds { + if !deletedIdSet.contains(id) { + allPaths.append(path) + } + } + + // 새로 업로드된 이미지 경로 추가 + if let newPaths = newImagePaths { + allPaths.append(contentsOf: newPaths) + } + + // 메인 이미지 경로 결정 + let mainImage: String + if let mainImg = state.images.first(where: { $0.isMain }) { + if state.originalImageIds.keys.contains(mainImg.filePath) { + // 기존 이미지가 메인 + mainImage = mainImg.filePath + } else { + // 새 이미지가 메인인 경우 + // 현재 이미지 배열에서의 위치 찾기 + let idx = state.images.firstIndex(where: { $0.filePath == mainImg.filePath }) ?? 0 + + // 해당 위치가 새 이미지 배열 범위 내에 있는지 확인 + if let newPaths = newImagePaths, idx < newPaths.count { + mainImage = newPaths[idx] + } else if !allPaths.isEmpty { + mainImage = allPaths[0] + } else { + mainImage = "" + } + } + } else if !allPaths.isEmpty { + mainImage = allPaths[0] + } else { + mainImage = "" + } + + // 업데이트 요청 생성 + let request = UpdatePopUpStoreRequestDTO( + popUpStore: .init( + id: storeId, + name: state.name, + categoryId: state.categoryId, + desc: state.description, + address: state.address, + startDate: dates.startDate, + endDate: dates.endDate, + mainImageUrl: mainImage, + bannerYn: !mainImage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + imageUrl: allPaths, + startDateBeforeEndDate: true + ), + location: .init( + latitude: Double(state.lat) ?? 0, + longitude: Double(state.lon) ?? 0, + markerTitle: state.markerTitle, + markerSnippet: state.markerSnippet + ), + imagesToAdd: newImagePaths ?? [], + imagesToDelete: state.deletedImageIds + ) + + // 서버에 스토어 정보 업데이트 요청 + return adminUseCase.updateStore(request: request) + .flatMap { [weak self] _ -> Observable in + guard let self = self else { return .empty() } + + // S3에서 삭제된 이미지 제거 + if !state.deletedImagePaths.isEmpty { + self.deleteImagesFromS3(state.deletedImagePaths) + } + + return .just(.setSuccess(true)) + } + + } } + diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift index 3c21a2e2..ac9dd346 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift @@ -9,14 +9,11 @@ import RxSwift import SnapKit import Then -final class PopUpStoreRegisterViewController: BaseViewController { +final class PopUpStoreRegisterViewController: BaseViewController, View { + typealias Reactor = PopUpStoreRegisterReactor - // MARK: - Navigation/Header - var completionHandler: (() -> Void)? - private var selectedImages: [UIImage] = [] - private var selectedMainImageIndex: Int? - private var imageFileNames: [String] = [] - private var images: [ExtendedImage] = [] + // MARK: - Properties + var disposeBag = DisposeBag() private var pickerViewController: PHPickerViewController? private let adminUseCase: AdminUseCase private var nameField: UITextField? @@ -24,33 +21,11 @@ final class PopUpStoreRegisterViewController: BaseViewController { private var latField: UITextField? private var lonField: UITextField? private var descTV: UITextView? - - private let popupName: String = "" - private var originalImageIds: [String: Int64] = [:] - private var deletedImageIds: [Int64] = [] - private var deletedImagePaths: [String] = [] - - private let editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? - let presignedService = PreSignedService() - - var disposeBag = DisposeBag() + var completionHandler: (() -> Void)? private let nickname: String - private let navContainer = UIView() - - init(nickname: String, adminUseCase: AdminUseCase, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { - self.nickname = nickname - self.adminUseCase = adminUseCase - self.editingStore = editingStore - super.init() - self.accountIdLabel.text = nickname + "님" - if editingStore != nil { - pageTitleLabel.text = "팝업스토어 수정" - } - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + // MARK: - UI Components + private let navContainer = UIView() private lazy var imagesCollectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() @@ -87,7 +62,6 @@ final class PopUpStoreRegisterViewController: BaseViewController { return btn }() - // MARK: - Title (Back button + label) private let titleContainer = UIView() private let backButton: UIButton = { let btn = UIButton(type: .system) @@ -114,11 +88,9 @@ final class PopUpStoreRegisterViewController: BaseViewController { $0.setTitleColor(.red, for: .normal) } - // MARK: - Scroll private let scrollView = UIScrollView() private let contentView = UIView() - // MARK: - Form Background private let formBackgroundView = UIView().then { $0.backgroundColor = .white $0.layer.borderWidth = 1 @@ -128,7 +100,6 @@ final class PopUpStoreRegisterViewController: BaseViewController { private let verticalStack = UIStackView() - // MARK: - Bottom Save Button private let saveButton: UIButton = { let btn = UIButton(type: .system) btn.setTitle("저장", for: .normal) @@ -140,16 +111,6 @@ final class PopUpStoreRegisterViewController: BaseViewController { return btn }() - // MARK: - DateTimePicker - private var selectedStartDate: Date? - private var selectedEndDate: Date? - private var selectedStartTime: Date? - private var selectedEndTime: Date? - - // MARK: - Categories - private var categories: [String] = ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", "엔터테인먼트", "여행", "예술", "음식/요리", "키즈", "패션"] - - // MARK: - UI Elements private let categoryButton: UIButton = { let btn = UIButton(type: .system) btn.setTitle("카테고리 선택 ▾", for: .normal) @@ -188,260 +149,294 @@ final class PopUpStoreRegisterViewController: BaseViewController { btn.contentEdgeInsets = UIEdgeInsets(top: 7, left: 8, bottom: 7, right: 8) return btn }() + private func extractDateRange(from state: Reactor.State) -> (Date, Date)? { + guard let start = state.selectedStartDate, + let end = state.selectedEndDate else { + return nil + } + return (start, end) + } + + private func extractTimeRange(from state: Reactor.State) -> (Date, Date)? { + guard let start = state.selectedStartTime, + let end = state.selectedEndTime else { + return nil + } + return (start, end) + } + + private func areDateRangesEqual(_ prev: (Date, Date), _ current: (Date, Date)) -> Bool { + return prev.0 == current.0 && prev.1 == current.1 + } + // MARK: - Initializer + init(nickname: String, adminUseCase: AdminUseCase, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { + self.nickname = nickname + self.adminUseCase = adminUseCase + + super.init() + + let presignedService = PreSignedService() + let reactor = PopUpStoreRegisterReactor( + adminUseCase: adminUseCase, + presignedService: presignedService, + editingStore: editingStore + ) + + self.reactor = reactor + self.accountIdLabel.text = nickname + "님" + + if editingStore != nil { + pageTitleLabel.text = "팝업스토어 수정" + + // 편집 모드일 경우 스토어 상세 정보 로드 + if let storeId = editingStore?.id { + reactor.action.onNext(.loadStoreDetail(storeId)) + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) tapGesture.cancelsTouchesInView = false view.addGestureRecognizer(tapGesture) view.backgroundColor = UIColor(white: 0.95, alpha: 1) - if let store = editingStore { - // 삭제된 이미지 ID 복원 - if let savedIds = UserDefaults.standard.array(forKey: "deletedImageIds_\(store.id)") as? [Int64] { - self.deletedImageIds = savedIds - Logger.log(message: "저장된 삭제 이미지 ID 복원: \(savedIds.count)개", category: .debug) - } - - // 삭제된 이미지 경로 복원 - if let savedPaths = UserDefaults.standard.array(forKey: "deletedImagePaths_\(store.id)") as? [String] { - self.deletedImagePaths = savedPaths - Logger.log(message: "저장된 삭제 이미지 경로 복원: \(savedPaths.count)개", category: .debug) - } - - loadStoreDetail(for: store.id) - } setupNavigation() setupLayout() setupRows() setupImageCollectionUI() - setupImageCollectionActions() setupKeyboardHandling() - setupAddressField() - setupAllFieldListeners() - } - // MARK: - Navigation - private func setupNavigation() { - backButton.addTarget(self, action: #selector(onBack), for: .touchUpInside) - } + // MARK: - ReactorKit Binding + func bind(reactor: Reactor) { - @objc private func handleTap() { - view.endEditing(true) - } - @objc private func onBack() { - navigationController?.popViewController(animated: true) - } - @objc private func fieldDidChange(_ textField: UITextField) { - if textField == addressField { - Logger.log(message: "주소 값 변경: \(textField.text ?? "nil")", category: .debug) - } else if textField == latField { - Logger.log(message: "위도 값 변경: \(textField.text ?? "nil")", category: .debug) - } else if textField == lonField { - Logger.log(message: "경도 값 변경: \(textField.text ?? "nil")", category: .debug) - updateSaveButtonState() + // 텍스트 필드 바인딩 + nameField?.rx.text.orEmpty + .distinctUntilChanged() + .skip(1) + .map { Reactor.Action.updateName($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) - } - } - private func fillFormWithExistingData(_ storeDetail: GetAdminPopUpStoreDetailResponseDTO) { - // 기본 필드 채우기 - nameField?.text = storeDetail.name - categoryButton.setTitle("\(storeDetail.categoryName) ▾", for: .normal) - addressField?.text = storeDetail.address - latField?.text = String(storeDetail.latitude) - lonField?.text = String(storeDetail.longitude) - descTV?.text = storeDetail.desc - - // 중요: ID와 URL 매핑 초기화 및 설정 - self.originalImageIds.removeAll() - // deletedImageIds와 deletedImagePaths는 초기화하지 않음 (기존 삭제 정보 유지) - - // 중요: 여기서 originalImageIds 맵을 세팅합니다 - for image in storeDetail.imageList { - self.originalImageIds[image.imageUrl] = image.id - Logger.log(message: "이미지 ID 매핑: \(image.imageUrl) -> \(image.id)", category: .debug) - } + addressField?.rx.text.orEmpty + .distinctUntilChanged() + .skip(1) + .map { Reactor.Action.updateAddress($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) - // 날짜 설정 - let isoFormatter = ISO8601DateFormatter() + latField?.rx.text.orEmpty + .distinctUntilChanged() + .skip(1) + .map { Reactor.Action.updateLat($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) - if let startDate = isoFormatter.date(from: storeDetail.startDate), - let endDate = isoFormatter.date(from: storeDetail.endDate) { - self.selectedStartDate = startDate - self.selectedEndDate = endDate - self.updatePeriodButtonTitle() - } + lonField?.rx.text.orEmpty + .distinctUntilChanged() + .skip(1) + .map { Reactor.Action.updateLon($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) - // 중요: 기존 이미지는 먼저 모두 제거 - self.images.removeAll() + descTV?.rx.text.orEmpty + .distinctUntilChanged() + .skip(1) + .map { Reactor.Action.updateDescription($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) - // 이미지 목록 디버깅 - 실제 서버에서 받은 목록 확인 - Logger.log(message: "서버에서 받은 이미지 목록 (총 \(storeDetail.imageList.count)개):", category: .debug) - for (index, image) in storeDetail.imageList.enumerated() { - Logger.log(message: " \(index+1). ID: \(image.id), URL: \(image.imageUrl)", category: .debug) - } + // 주소 변경 시 지오코딩 요청 + addressField?.rx.text.orEmpty + .distinctUntilChanged() + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .filter { !$0.isEmpty } + .map { Reactor.Action.geocodeAddress($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) - // 삭제된 이미지 ID 집합 생성 (빠른 검색을 위해) - let deletedIdSet = Set(self.deletedImageIds) - Logger.log(message: "삭제된 이미지 ID 목록: \(deletedIdSet)", category: .debug) + // 이미지 버튼 바인딩 + addImageButton.rx.tap + .bind { [weak self] in + self?.showImagePicker() + } + .disposed(by: disposeBag) - // 중복 및 삭제된 이미지 제외를 위한 집합 - var loadedImageUrls = Set() + removeAllButton.rx.tap + .map { Reactor.Action.removeAllImages } + .bind(to: reactor.action) + .disposed(by: disposeBag) - let dispatchGroup = DispatchGroup() - let mainImageUrl = storeDetail.mainImageUrl - Logger.log(message: "대표 이미지 URL: \(mainImageUrl)", category: .debug) - - for imageData in storeDetail.imageList { - // 중복 이미지 건너뛰기 - if loadedImageUrls.contains(imageData.imageUrl) { - Logger.log(message: "중복 이미지 스킵: \(imageData.imageUrl)", category: .debug) - continue + // 다양한 버튼 바인딩 + categoryButton.rx.tap + .bind { [weak self] in + self?.showCategoryPicker() } + .disposed(by: disposeBag) - // 삭제된 이미지 건너뛰기 - if deletedIdSet.contains(imageData.id) { - Logger.log(message: "삭제된 이미지 스킵: ID \(imageData.id), URL: \(imageData.imageUrl)", category: .debug) - continue + periodButton.rx.tap + .bind { [weak self] in + self?.showDateRangePicker() } + .disposed(by: disposeBag) - loadedImageUrls.insert(imageData.imageUrl) - - dispatchGroup.enter() - - if let imageURL = presignedService.fullImageURL(from: imageData.imageUrl) { - Logger.log(message: "이미지 로드 시작: \(imageData.imageUrl)", category: .debug) - - URLSession.shared.dataTask(with: imageURL) { [weak self] data, response, error in - defer { dispatchGroup.leave() } - - // 응답 상태 코드 확인 추가 - if let httpResponse = response as? HTTPURLResponse { - Logger.log(message: "이미지 로드 응답 코드: \(httpResponse.statusCode) - URL: \(imageData.imageUrl)", category: .debug) - if httpResponse.statusCode != 200 { - Logger.log(message: "이미지 로드 실패 - 상태 코드: \(httpResponse.statusCode)", category: .error) - return - } - } - - if let error = error { - Logger.log(message: "이미지 로드 오류: \(error.localizedDescription)", category: .error) - return - } - - guard let self = self, - let data = data, - let image = UIImage(data: data) else { - Logger.log(message: "이미지 변환 실패", category: .error) - return - } - - let isMain = (imageData.imageUrl == mainImageUrl) - - let extendedImage = ExtendedImage(filePath: imageData.imageUrl, image: image, isMain: isMain) - - DispatchQueue.main.async { - self.images.append(extendedImage) - Logger.log(message: "이미지 로드 완료: \(imageData.imageUrl), isMain: \(isMain)", category: .debug) - } - }.resume() - } else { - Logger.log(message: "이미지 URL 생성 실패: \(imageData.imageUrl)", category: .error) - dispatchGroup.leave() + timeButton.rx.tap + .bind { [weak self] in + self?.showTimeRangePicker() } - } + .disposed(by: disposeBag) - dispatchGroup.notify(queue: .main) { [weak self] in - guard let self = self else { return } + // 저장 버튼 + saveButton.rx.tap + .map { Reactor.Action.save } + .bind(to: reactor.action) + .disposed(by: disposeBag) - if !self.images.isEmpty && !self.images.contains(where: { $0.isMain }) { - self.images[0].isMain = true - Logger.log(message: "대표 이미지가 없어 첫 번째 이미지를 대표로 설정", category: .debug) - } + // Outputs (Reactor -> View) - Logger.log(message: "모든 이미지 로드 완료: 총 \(self.images.count)개", category: .debug) - self.imagesCollectionView.reloadData() - self.updateSaveButtonState() - } - } + // 저장 버튼 활성화 상태 + reactor.state.map { $0.isSaveEnabled } + .distinctUntilChanged() + .bind(onNext: { [weak self] isEnabled in + self?.saveButton.isEnabled = isEnabled + self?.saveButton.backgroundColor = isEnabled ? .systemBlue : .lightGray + }) + .disposed(by: disposeBag) + + // 로딩 상태 + reactor.state.map { $0.isLoading } + .distinctUntilChanged() + .bind(onNext: { [weak self] isLoading in + if isLoading { + // 로딩 인디케이터 표시 + self?.showLoadingIndicator() + } else { + // 로딩 인디케이터 숨김 + self?.hideLoadingIndicator() + } + }) + .disposed(by: disposeBag) - func loadStoreDetail(for storeId: Int64) { - Logger.log(message: "상세 정보 요청 시작 - Store ID: \(storeId)", category: .debug) + // 에러 메시지 + reactor.state.map { $0.errorMessage } + .distinctUntilChanged() + .compactMap { $0 } + .bind(onNext: { [weak self] message in + self?.showErrorAlert(message: message) + reactor.action.onNext(.clearError) + }) + .disposed(by: disposeBag) - adminUseCase.fetchStoreDetail(id: storeId) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] storeDetail in - Logger.log(message: "상세 정보 요청 성공", category: .info) - self?.fillFormWithExistingData(storeDetail) - }, onError: { error in - Logger.log(message: "상세 정보 요청 실패: \(error.localizedDescription)", category: .error) + // 성공 상태 + reactor.state.map { $0.isSuccess } + .distinctUntilChanged() + .filter { $0 } + .bind(onNext: { [weak self] _ in + self?.showSuccessAlert(isUpdate: reactor.currentState.isEditMode) + reactor.action.onNext(.dismissSuccess) }) .disposed(by: disposeBag) - } - private func setupKeyboardHandling() { - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) + // 이미지 목록 업데이트 + reactor.state.map { $0.images } + .distinctUntilChanged() + .bind(onNext: { [weak self] _ in + self?.imagesCollectionView.reloadData() + }) + .disposed(by: disposeBag) - // 스크롤뷰 키보드 처리 설정 - scrollView.keyboardDismissMode = .interactive - } + // 필드 값 업데이트 + reactor.state.map { $0.name } + .distinctUntilChanged() + .bind(onNext: { [weak self] name in + if self?.nameField?.text != name { + self?.nameField?.text = name + } + }) + .disposed(by: disposeBag) - @objc private func keyboardWillShow(_ notification: Notification) { - guard let userInfo = notification.userInfo, - let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { - return - } + reactor.state.map { $0.address } + .distinctUntilChanged() + .bind(onNext: { [weak self] address in + if self?.addressField?.text != address { + self?.addressField?.text = address + } + }) + .disposed(by: disposeBag) - let keyboardHeight = keyboardFrame.height - let contentInset = UIEdgeInsets( - top: 0, - left: 0, - bottom: keyboardHeight, - right: 0 - ) + reactor.state.map { $0.lat } + .distinctUntilChanged() + .bind(onNext: { [weak self] lat in + if self?.latField?.text != lat { + self?.latField?.text = lat + } + }) + .disposed(by: disposeBag) - scrollView.contentInset = contentInset - scrollView.scrollIndicatorInsets = contentInset + reactor.state.map { $0.lon } + .distinctUntilChanged() + .bind(onNext: { [weak self] lon in + if self?.lonField?.text != lon { + self?.lonField?.text = lon + } + }) + .disposed(by: disposeBag) - // 현재 활성화된 필드가 키보드에 가려지는지 확인 - if let activeField = view.findFirstResponder() { - let activeRect = activeField.convert(activeField.bounds, to: scrollView) - let bottomOffset = activeRect.maxY + 20 // 여유 공간 + reactor.state.map { $0.description } + .distinctUntilChanged() + .bind(onNext: { [weak self] description in + if self?.descTV?.text != description { + self?.descTV?.text = description + } + }) + .disposed(by: disposeBag) - if bottomOffset > (scrollView.frame.height - keyboardHeight) { - let scrollPoint = CGPoint( - x: 0, - y: bottomOffset - (scrollView.frame.height - keyboardHeight) - ) - scrollView.setContentOffset(scrollPoint, animated: true) - } - } - } + reactor.state.map { $0.category } + .distinctUntilChanged() + .filter { !$0.isEmpty } + .bind(onNext: { [weak self] category in + self?.updateCategoryButtonTitle(with: category) + }) + .disposed(by: disposeBag) - @objc private func keyboardWillHide(_ notification: Notification) { - UIView.animate(withDuration: 0.3) { - self.scrollView.contentInset = .zero - self.scrollView.scrollIndicatorInsets = .zero - } + // 날짜 범위 업데이트 + reactor.state + .compactMap { [weak self] state in + self?.extractDateRange(from: state) + } + .distinctUntilChanged(areDateRangesEqual) + .bind(onNext: { [weak self] dateRange in + self?.updatePeriodButtonTitle(start: dateRange.0, end: dateRange.1) + }) + .disposed(by: disposeBag) + + // 시간 범위 업데이트 + reactor.state + .compactMap { [weak self] state in + self?.extractTimeRange(from: state) + } + .distinctUntilChanged(areDateRangesEqual) + .bind(onNext: { [weak self] timeRange in + self?.updateTimeButtonTitle(start: timeRange.0, end: timeRange.1) + }) + .disposed(by: disposeBag) + } + // MARK: - UI Setup + private func setupNavigation() { + backButton.addTarget(self, action: #selector(onBack), for: .touchUpInside) } - // MARK: - Layout private func setupLayout() { - // (1) 상단 컨테이너 + // 상단 컨테이너 view.addSubview(navContainer) navContainer.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) @@ -470,7 +465,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.width.height.equalTo(32) } - // (2) 타이틀 컨테이너 + // 타이틀 컨테이너 view.addSubview(titleContainer) titleContainer.snp.makeConstraints { make in make.top.equalTo(navContainer.snp.bottom) @@ -491,7 +486,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.left.equalTo(backButton.snp.right).offset(4) } - // (3) 스크롤뷰 + // 스크롤뷰 view.addSubview(scrollView) scrollView.snp.makeConstraints { make in make.top.equalTo(titleContainer.snp.bottom) @@ -505,7 +500,17 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.width.equalTo(scrollView.snp.width) } - // (4) 이미지 영역 추가 + // 저장 버튼 + view.addSubview(saveButton) + saveButton.snp.makeConstraints { make in + make.left.right.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + make.height.equalTo(44) + } + } + + private func setupImageCollectionUI() { + // 버튼 스택 let buttonStack = UIStackView(arrangedSubviews: [addImageButton, removeAllButton]) buttonStack.axis = .horizontal buttonStack.distribution = .fillEqually @@ -518,6 +523,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.height.equalTo(40) } + // 이미지 컬렉션 뷰 contentView.addSubview(imagesCollectionView) imagesCollectionView.snp.makeConstraints { make in make.top.equalTo(buttonStack.snp.bottom).offset(8) @@ -525,7 +531,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.height.equalTo(130) } - // (5) 폼 배경 + // 폼 배경 contentView.addSubview(formBackgroundView) formBackgroundView.snp.makeConstraints { make in make.top.equalTo(imagesCollectionView.snp.bottom).offset(16) @@ -533,80 +539,35 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.bottom.equalToSuperview().offset(-16) } + // 수직 스택 formBackgroundView.addSubview(verticalStack) verticalStack.axis = .vertical verticalStack.spacing = 0 verticalStack.snp.makeConstraints { make in make.edges.equalToSuperview() } - - // (6) 저장 버튼 - view.addSubview(saveButton) - saveButton.snp.makeConstraints { make in - make.left.right.equalToSuperview().inset(16) - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) - make.height.equalTo(44) - } - } - private func getCurrentFormattedTime() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd HH:mm" - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대 명시적 설정 - return formatter.string(from: Date()) - } - private func setupCreationTimeLabel() -> UILabel { - let currentTime = getCurrentFormattedTime() - return makeSimpleLabel(currentTime) } - private func setupAllFieldListeners() { - // 이름 필드 - nameField?.addTarget(self, action: #selector(anyFieldDidChange(_:)), for: .editingChanged) - - // 주소, 위도, 경도 필드 - addressField?.addTarget(self, action: #selector(anyFieldDidChange(_:)), for: .editingChanged) - latField?.addTarget(self, action: #selector(anyFieldDidChange(_:)), for: .editingChanged) - lonField?.addTarget(self, action: #selector(anyFieldDidChange(_:)), for: .editingChanged) - - // 설명 필드 (UITextView는 다르게 처리해야 함) - descTV?.delegate = self - - // 로그 추가 - Logger.log(message: "모든 필드에 변경 리스너가 설정되었습니다", category: .debug) - } - @objc private func anyFieldDidChange(_ textField: UITextField) { - Logger.log(message: "\(textField.accessibilityIdentifier ?? "알 수 없는 필드") 값 변경: \(textField.text ?? "nil")", category: .debug) - updateSaveButtonState() - } - // MARK: - Setup Rows private func setupRows() { + // 이름 필드 추가 addRowTextField(leftTitle: "이름", placeholder: "팝업스토어 이름을 입력해 주세요.") -// addRowTextField(leftTitle: "이미지", placeholder: "팝업스토어 대표 이미지를 업로드 해주세요.") - categoryButton.addTarget(self, action: #selector(didTapCategoryButton), for: .touchUpInside) + // 카테고리 버튼 추가 addRowCustom(leftTitle: "카테고리", rightView: categoryButton) - // (위치) => 2줄 - // 1) 주소 (TextField) + // 위치 필드 추가 (주소, 위도, 경도) let addressField = makeRoundedTextField("팝업스토어 주소를 입력해 주세요.") self.addressField = addressField - addressField.snp.makeConstraints { _ in - addressField.addTarget(self, action: #selector(fieldDidChange(_:)), for: .editingChanged) - - } - // 2) (위도 Label + TF) + (경도 Label + TF) let latLabel = makePlainLabel("위도") let latField = makeRoundedTextField("") latField.textAlignment = .center - self.latField = latField // latField와 연결 - latField.addTarget(self, action: #selector(fieldDidChange(_:)), for: .editingChanged) + self.latField = latField let lonLabel = makePlainLabel("경도") let lonField = makeRoundedTextField("") - self.lonField = lonField // lonField와 연결 lonField.textAlignment = .center - lonField.addTarget(self, action: #selector(fieldDidChange(_:)), for: .editingChanged) + self.lonField = lonField let latStack = UIStackView(arrangedSubviews: [latLabel, latField]) latStack.axis = .horizontal @@ -623,27 +584,21 @@ final class PopUpStoreRegisterViewController: BaseViewController { latLonRow.spacing = 16 latLonRow.distribution = .fillEqually - // 수직 스택(주소, latLonRow) let locationVStack = UIStackView(arrangedSubviews: [addressField, latLonRow]) locationVStack.axis = .vertical locationVStack.spacing = 8 locationVStack.distribution = .fillEqually - // 한 행에 왼쪽 "위치", 오른쪽 2줄(주소 / 위도경도) addRowCustom(leftTitle: "위치", rightView: locationVStack, rowHeight: nil, totalHeight: 80) - // (마커) => 2줄 - // 1) (마커명 Label + TF) + // 마커 필드 추가 let markerLabel = makePlainLabel("마커명") - let markerField = makeRoundedTextField("") - let markerStackH = UIStackView(arrangedSubviews: [markerLabel, markerField]) markerStackH.axis = .horizontal markerStackH.spacing = 8 markerStackH.distribution = .fillProportionally - // 2) (스니펫 Label + TF) let snippetLabel = makePlainLabel("스니펫") let snippetField = makeRoundedTextField("") let snippetStackH = UIStackView(arrangedSubviews: [snippetLabel, snippetField]) @@ -651,133 +606,60 @@ final class PopUpStoreRegisterViewController: BaseViewController { snippetStackH.spacing = 8 snippetStackH.distribution = .fillProportionally - // 수직 let markerVStack = UIStackView(arrangedSubviews: [markerStackH, snippetStackH]) markerVStack.axis = .vertical markerVStack.spacing = 8 markerVStack.distribution = .fillEqually - // 한 행 => "마커" 라벨, 오른쪽 2줄 (마커명, 스니펫) addRowCustom(leftTitle: "마커", rightView: markerVStack, rowHeight: nil, totalHeight: 80) - // (10) 기간 - periodButton.addTarget(self, action: #selector(didTapPeriodButton), for: .touchUpInside) + // 기간 및 시간 addRowCustom(leftTitle: "기간", rightView: periodButton) - - // (11) 시간 - timeButton.addTarget(self, action: #selector(didTapTimeButton), for: .touchUpInside) addRowCustom(leftTitle: "시간", rightView: timeButton) - // (12) 작성자 + // 작성자 및 작성시간 let writerLbl = makeSimpleLabel(nickname) addRowCustom(leftTitle: "작성자", rightView: writerLbl) - // (13) 작성시간 let timeLbl = setupCreationTimeLabel() addRowCustom(leftTitle: "작성시간", rightView: timeLbl) - // (14) 상태값 + // 상태값 let statusLbl = makeSimpleLabel("진행") addRowCustom(leftTitle: "상태값", rightView: statusLbl) - // (15) 설명 + // 설명 let descTV = makeRoundedTextView() - self.descTV = descTV // 설명 필드 연결 + self.descTV = descTV addRowCustom(leftTitle: "설명", rightView: descTV, rowHeight: nil, totalHeight: 120) - } - // MARK: - Row - - private func addRowTextField(leftTitle: String, placeholder: String) { - let tf = makeRoundedTextField(placeholder) - if leftTitle == "이름" { - nameField = tf // 이름 필드 연결 - } else if leftTitle == "주소" { - addressField = tf // 주소 필드 연결 - } - addRowCustom(leftTitle: leftTitle, rightView: tf) - } - - private func setupImageCollectionUI() { - // 1) 상단 버튼들 (Add / RemoveAll) - let buttonStack = UIStackView(arrangedSubviews: [addImageButton, removeAllButton]) - buttonStack.axis = .horizontal - buttonStack.distribution = .fillEqually - buttonStack.spacing = 16 - - contentView.addSubview(buttonStack) - buttonStack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.left.right.equalToSuperview().inset(16) - make.height.equalTo(40) - } - - // 2) CollectionView - contentView.addSubview(imagesCollectionView) - imagesCollectionView.snp.makeConstraints { make in - make.top.equalTo(buttonStack.snp.bottom).offset(8) - make.left.right.equalToSuperview().inset(16) - make.height.equalTo(130) // 셀 높이(120) + 패딩 - } - - // formBackgroundView를 아래로 조금 내려야 한다면? - formBackgroundView.snp.remakeConstraints { make in - make.top.equalTo(imagesCollectionView.snp.bottom).offset(16) - make.left.right.equalToSuperview().inset(16) - make.bottom.equalToSuperview() - } - } - private func setupImageCollectionActions() { - // (1) 이미지 추가 버튼 -> 앨범 열기 - addImageButton.rx.tap - .bind { [weak self] in - self?.showImagePicker() - } - .disposed(by: disposeBag) - - // (2) 전체 삭제 버튼 - removeAllButton.rx.tap - .bind { [weak self] in - self?.images.removeAll() - self?.imagesCollectionView.reloadData() - self?.updateSaveButtonState() - } - .disposed(by: disposeBag) + private func setupKeyboardHandling() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) - saveButton.rx.tap - .bind { [weak self] in - guard let self = self else { return } - // 1) 유효성 검사 - if self.validateForm() { - // 2) OK -> 등록 로직 - self.doRegister() - } else { - // 3) 실패 -> Alert/toast - let alert = UIAlertController( - title: "필수값 미입력", - message: "필수 항목을 모두 입력해 주세요.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - self.present(alert, animated: true, completion: nil) - } - } - .disposed(by: disposeBag) + scrollView.keyboardDismissMode = .interactive } - private func updateSaveButtonState() { - // 디버깅을 위한 로깅 추가 - Logger.log(message: "updateSaveButtonState 호출됨", category: .debug) - - let isFormValid = validateForm() - // 이전 상태와 새 상태가 다를 때만 로그 출력 - if saveButton.isEnabled != isFormValid { - Logger.log(message: "저장 버튼 상태 변경: \(saveButton.isEnabled) -> \(isFormValid)", category: .debug) + // MARK: - Helper Methods + private func addRowTextField(leftTitle: String, placeholder: String) { + let tf = makeRoundedTextField(placeholder) + if leftTitle == "이름" { + nameField = tf + } else if leftTitle == "주소" { + addressField = tf } - - saveButton.isEnabled = isFormValid - saveButton.backgroundColor = isFormValid ? .systemBlue : .lightGray + addRowCustom(leftTitle: leftTitle, rightView: tf) } private func addRowCustom(leftTitle: String, @@ -846,136 +728,18 @@ final class PopUpStoreRegisterViewController: BaseViewController { verticalStack.addArrangedSubview(row) } - @objc private func didTapPeriodButton() { - DateTimePickerManager.shared.showDateRange(on: self) { [weak self] start, end in - guard let self = self else { return } - self.selectedStartDate = start - self.selectedEndDate = end - self.updatePeriodButtonTitle() - self.updateSaveButtonState() // 날짜 선택 후 저장 버튼 상태 업데이트 - } - } - - @objc private func didTapTimeButton() { - DateTimePickerManager.shared.showTimeRange(on: self) { [weak self] st, et in - guard let self = self else { return } - self.selectedStartTime = st - self.selectedEndTime = et - - // 디버깅을 위한 로그 추가 - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - Logger.log(message: "시간 선택 완료 - 시작: \(formatter.string(from: st)), 종료: \(formatter.string(from: et))", category: .debug) - - self.updateTimeButtonTitle() - self.updateSaveButtonState() - } - } - - private func updatePeriodButtonTitle() { - guard let selectedStartDate = selectedStartDate, let selectedEndDate = selectedEndDate else { return } - let df = DateFormatter() - df.dateFormat = "yyyy.MM.dd" - let sStr = df.string(from: selectedStartDate) - let eStr = df.string(from: selectedEndDate) - - periodButton.setTitle("\(sStr) ~ \(eStr)", for: .normal) - } - - private func updateTimeButtonTitle() { - guard let st = selectedStartTime, let et = selectedEndTime else { return } - let df = DateFormatter() - df.dateFormat = "HH:mm" - let stStr = df.string(from: st) - let etStr = df.string(from: et) - timeButton.setTitle("\(stStr) ~ \(etStr)", for: .normal) - } - - // MARK: - Category Selection - - @objc private func didTapCategoryButton() { - let alertController = UIAlertController(title: "카테고리 선택", message: nil, preferredStyle: .actionSheet) - - // 기존 카테고리 목록 추가 - for category in categories { - let action = UIAlertAction(title: category, style: .default) { [weak self] _ in - self?.updateCategoryButtonTitle(with: category) - } - alertController.addAction(action) - } - - // '카테고리 추가' 옵션 추가 - let addAction = UIAlertAction(title: "카테고리 추가", style: .default) { [weak self] _ in - self?.presentAddCategoryAlert() - } - alertController.addAction(addAction) - - // 취소 버튼 추가 - let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil) - alertController.addAction(cancelAction) - - // iPad에서 액션 시트가 크래시되지 않도록 설정 - if let popoverController = alertController.popoverPresentationController { - popoverController.sourceView = categoryButton - popoverController.sourceRect = categoryButton.bounds - } - - present(alertController, animated: true, completion: nil) - } - - private func presentAddCategoryAlert() { - let alert = UIAlertController(title: "새 카테고리 추가", message: "추가할 카테고리 이름을 입력하세요.", preferredStyle: .alert) - - alert.addTextField { textField in - textField.placeholder = "카테고리 이름" - } - - let addAction = UIAlertAction(title: "추가", style: .default) { [weak self] _ in - guard let self = self else { return } - if let newCategory = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines), !newCategory.isEmpty { - // 중복 체크 - if self.categories.contains(newCategory) { - self.presentDuplicateCategoryAlert() - } else { - self.categories.append(newCategory) - self.updateCategoryButtonTitle(with: newCategory) - } - } - } - - let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil) - - alert.addAction(addAction) - alert.addAction(cancelAction) - - present(alert, animated: true, completion: nil) - } - private func showImagePicker() { - // 1) PHPicker 설정 - var configuration = PHPickerConfiguration() - configuration.filter = .images // 이미지만 - configuration.selectionLimit = 0 // 0이면 무제한, 혹은 10, 5 등 제한 가능 - - let picker = PHPickerViewController(configuration: configuration) - picker.delegate = self - self.pickerViewController = picker - - // 2) 모달 표시 - present(picker, animated: true, completion: nil) - } - - private func presentDuplicateCategoryAlert() { - let alert = UIAlertController(title: "중복", message: "이미 존재하는 카테고리입니다.", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "확인", style: .default, handler: nil)) - present(alert, animated: true, completion: nil) + private func getCurrentFormattedTime() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd HH:mm" + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + return formatter.string(from: Date()) } - private func updateCategoryButtonTitle(with category: String) { - categoryButton.setTitle("\(category) ▾", for: .normal) -     updateSaveButtonState() + private func setupCreationTimeLabel() -> UILabel { + let currentTime = getCurrentFormattedTime() + return makeSimpleLabel(currentTime) } - // MARK: - UI Helpers private func makeRoundedTextField(_ placeholder: String) -> UITextField { let tf = UITextField() tf.placeholder = placeholder @@ -989,548 +753,163 @@ final class PopUpStoreRegisterViewController: BaseViewController { return tf } - private func makeRoundedButton(_ title: String) -> UIButton { - let btn = UIButton(type: .system) - btn.setTitle(title, for: .normal) - btn.setTitleColor(.darkGray, for: .normal) - btn.titleLabel?.font = UIFont.systemFont(ofSize: 14) - btn.layer.cornerRadius = 8 - btn.layer.borderWidth = 1 - btn.layer.borderColor = UIColor.lightGray.cgColor - btn.contentHorizontalAlignment = .left - btn.contentEdgeInsets = UIEdgeInsets(top: 7, left: 8, bottom: 7, right: 8) - return btn - } - - private func makeIconButton(_ title: String, iconName: String) -> UIButton { - let btn = makeRoundedButton(title) - if let icon = UIImage(named: iconName) { - btn.setImage(icon, for: .normal) - btn.imageView?.contentMode = .scaleAspectFit - btn.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 0) - } - return btn + private func makeRoundedTextView() -> UITextView { + let tv = UITextView() + tv.font = UIFont.systemFont(ofSize: 14) + tv.textColor = .darkGray + tv.layer.cornerRadius = 8 + tv.layer.borderWidth = 1 + tv.layer.borderColor = UIColor.lightGray.cgColor + tv.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) + tv.isScrollEnabled = true + return tv } - private func makeSimpleLabel(_ text: String) -> UILabel { + private func makePlainLabel(_ text: String) -> UILabel { let lbl = UILabel() lbl.text = text lbl.font = UIFont.systemFont(ofSize: 14) lbl.textColor = .darkGray + lbl.textAlignment = .right + lbl.setContentHuggingPriority(.required, for: .horizontal) return lbl } - private func makePlainLabel(_ text: String) -> UILabel { - // 작은 라벨(위도/경도/마커명/스니펫 등) + private func makeSimpleLabel(_ text: String) -> UILabel { let lbl = UILabel() lbl.text = text lbl.font = UIFont.systemFont(ofSize: 14) lbl.textColor = .darkGray - lbl.textAlignment = .right - lbl.setContentHuggingPriority(.required, for: .horizontal) return lbl } - private func makeRoundedTextView() -> UITextView { - let tv = UITextView() - tv.font = UIFont.systemFont(ofSize: 14) - tv.textColor = .darkGray - tv.layer.cornerRadius = 8 - tv.layer.borderWidth = 1 - tv.layer.borderColor = UIColor.lightGray.cgColor - tv.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) - tv.isScrollEnabled = true - return tv + // MARK: - UI Interaction Methods + @objc private func handleTap() { + view.endEditing(true) } -} -// MARK: - Padding -private extension UITextField { - func setLeftPaddingPoints(_ amount: CGFloat) { - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: amount, height: frame.size.height)) - leftView = paddingView - leftViewMode = .always - } -} -extension PopUpStoreRegisterViewController: UICollectionViewDataSource, UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return images.count + @objc private func onBack() { + navigationController?.popViewController(animated: true) } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: ImageCell.identifier, - for: indexPath - ) as? ImageCell else { - return UICollectionViewCell() + @objc private func keyboardWillShow(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return } - let item = images[indexPath.item] - cell.configure(with: item) - - // 대표이미지 변경 - cell.onMainCheckToggled = { [weak self] in - self?.toggleMainImage(index: indexPath.item) - } - // 개별 삭제 - cell.onDeleteTapped = { [weak self] in - self?.deleteImage(index: indexPath.item) - } + let keyboardHeight = keyboardFrame.height + let contentInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: keyboardHeight, + right: 0 + ) - return cell - } -} + scrollView.contentInset = contentInset + scrollView.scrollIndicatorInsets = contentInset -// 헬퍼 메서드들 -private extension PopUpStoreRegisterViewController { - /// 대표이미지를 단 하나만 허용 -> 누른 index만 isMain = true - func toggleMainImage(index: Int) { - for imageIndex in 0.. (scrollView.frame.height - keyboardHeight) { + let scrollPoint = CGPoint( + x: 0, + y: bottomOffset - (scrollView.frame.height - keyboardHeight) + ) + scrollView.setContentOffset(scrollPoint, animated: true) } } - - // S3 삭제 로직 제거 - 서버 업데이트 후로 이동 - - // 이미지 배열에서 제거 - images.remove(at: index) - - // 대표 이미지가 삭제되었고, 다른 이미지가 남아있다면 첫 번째 이미지를 대표 이미지로 설정 - if wasMainImage && !images.isEmpty { - images[0].isMain = true - } - - imagesCollectionView.reloadData() - updateSaveButtonState() } -} -extension PopUpStoreRegisterViewController: PHPickerViewControllerDelegate { - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - picker.dismiss(animated: true) - guard !results.isEmpty else { return } - - // nameField로부터 이름을 가져옴 - let name = self.nameField?.text ?? "unnamed" - let uuid = UUID().uuidString - - var newImages = [ExtendedImage]() - let itemProviders = results.map(\.itemProvider) - let dispatchGroup = DispatchGroup() - - // 이미 로드된 이미지 경로 목록 (중복 방지) - let existingPaths = Set(self.images.map { $0.filePath }) - Logger.log(message: "기존 이미지 경로 수: \(existingPaths.count)", category: .debug) - - for (index, provider) in itemProviders.enumerated() { - if provider.canLoadObject(ofClass: UIImage.self) { - dispatchGroup.enter() - provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in - defer { dispatchGroup.leave() } - guard let self = self, - let image = object as? UIImage else { return } - - let filePath = "PopUpImage/\(name)/\(uuid)/\(index).jpg" - - // 이미 같은 경로가 있는지 확인 (거의 발생하지 않겠지만 안전장치) - if existingPaths.contains(filePath) { - Logger.log(message: "중복된 이미지 경로 발견: \(filePath)", category: .debug) - return - } - - let extended = ExtendedImage( - filePath: filePath, - image: image, - isMain: false - ) - newImages.append(extended) - } - } + @objc private func keyboardWillHide(_ notification: Notification) { + UIView.animate(withDuration: 0.3) { + self.scrollView.contentInset = .zero + self.scrollView.scrollIndicatorInsets = .zero } + } - dispatchGroup.notify(queue: .main) { - if newImages.isEmpty { - Logger.log(message: "추가할 새 이미지가 없음", category: .debug) - return - } - - Logger.log(message: "새 이미지 \(newImages.count)개 추가", category: .debug) - self.images.append(contentsOf: newImages) + private func showImagePicker() { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 0 // 무제한 - if !self.images.isEmpty && !self.images.contains(where: { $0.isMain }) { - self.images[0].isMain = true // 첫 번째 이미지를 대표 이미지로 - Logger.log(message: "대표 이미지 설정: \(self.images[0].filePath)", category: .debug) - } + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + self.pickerViewController = picker - self.imagesCollectionView.reloadData() - self.updateSaveButtonState() - } + present(picker, animated: true, completion: nil) } -} - -private extension PopUpStoreRegisterViewController { - private func validateForm() -> Bool { - // (1) 팝업스토어 이름 - Logger.log(message: "nameField.text = \(nameField?.text ?? "nil")", category: .debug) - guard let nameField = nameField, !(nameField.text ?? "").isEmpty else { - Logger.log( - message: "이름 필드가 비어 있습니다.", - category: .debug, - fileName: #file, - line: #line - ) - return false - } - - // (2) 카테고리 선택 - if categoryButton.title(for: .normal) == "카테고리 선택 ▾" { - Logger.log( - message: "카테고리가 선택되지 않았습니다.", - category: .debug, - fileName: #file, - line: #line - ) - return false - } - // (3) 주소 - Logger.log(message: "addressField = \(addressField != nil ? "초기화됨" : "nil")", category: .debug) - Logger.log(message: "addressField.text = \(addressField?.text ?? "nil")", category: .debug) - guard let addressField = addressField, !(addressField.text ?? "").isEmpty else { - Logger.log(message: "주소 필드가 비어 있습니다.", category: .debug) - return false - } + private func showCategoryPicker() { + let categories = ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", "엔터테인먼트", "여행", "예술", "음식/요리", "키즈", "패션"] - // (4) 위도/경도 - Logger.log(message: "latField.text = \(latField?.text ?? "nil")", category: .debug) - Logger.log(message: "lonField.text = \(lonField?.text ?? "nil")", category: .debug) - guard let latField = latField, - let lonField = lonField, - let latText = latField.text, !latText.isEmpty, - let lonText = lonField.text, !lonText.isEmpty, - let latVal = Double(latText), let lonVal = Double(lonText), - latVal != 0 || lonVal != 0 else { - Logger.log( - message: "위도/경도 값이 잘못되었습니다.", - category: .debug, - fileName: #file, - line: #line - ) - return false - } + let alertController = UIAlertController(title: "카테고리 선택", message: nil, preferredStyle: .actionSheet) - // (5) 설명 - guard let descTV = descTV, !(descTV.text ?? "").isEmpty else { - Logger.log( - message: "설명 필드가 비어 있습니다.", - category: .debug, - fileName: #file, - line: #line - ) - return false + for category in categories { + let action = UIAlertAction(title: category, style: .default) { [weak self] _ in + self?.reactor?.action.onNext(.selectCategory(category)) + } + alertController.addAction(action) } - // (6) 이미지 ≥ 1장 - if images.isEmpty { - Logger.log( - message: "이미지가 추가되지 않았습니다.", - category: .debug, - fileName: #file, - line: #line - ) - return false - } + let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil) + alertController.addAction(cancelAction) - // (7) 대표 이미지 설정 여부 - if !images.contains(where: { $0.isMain }) { - Logger.log( - message: "대표 이미지가 설정되지 않았습니다.", - category: .debug, - fileName: #file, - line: #line - ) - return false + if let popoverController = alertController.popoverPresentationController { + popoverController.sourceView = categoryButton + popoverController.sourceRect = categoryButton.bounds } - Logger.log( - message: "모든 조건이 충족되었습니다.", - category: .info, - fileName: #file, - line: #line - ) - return true + present(alertController, animated: true, completion: nil) } - private func doRegister() { - Logger.log(message: "doRegister() 호출됨", category: .debug) - // 1. 폼 데이터 검증 - guard validateFormData() else { return } - - if let editingStore = editingStore { - // 수정 모드 - updateStore(editingStore) - } else { - // 새로 등록 모드 - // 2. 이미지 업로드 실행 - uploadImages() + private func showDateRangePicker() { + DateTimePickerManager.shared.showDateRange(on: self) { [weak self] start, end in + self?.reactor?.action.onNext(.selectDateRange(start: start, end: end)) } } - // 폼 데이터 검증 - private func validateFormData() -> Bool { - guard let name = nameField?.text, - let address = addressField?.text, - let latitude = latField?.text, Double(latitude) != nil, - let longitude = lonField?.text, Double(longitude) != nil, - let description = descTV?.text, - !images.isEmpty else { - Logger.log(message: "폼 데이터 검증 실패", category: .error) - return false + private func showTimeRangePicker() { + DateTimePickerManager.shared.showTimeRange(on: self) { [weak self] start, end in + self?.reactor?.action.onNext(.selectTimeRange(start: start, end: end)) } - Logger.log(message: "폼 데이터 검증 성공", category: .debug) - return true } - private func updateStore(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { - // 기존에 저장된 이미지는 재업로드하지 않고 그대로 사용 - // 새로 추가된 이미지만 업로드 - // 새로 추가된 이미지만 필터링 - let newImages = images.filter { image in - return !originalImageIds.keys.contains(image.filePath) - } - - if !newImages.isEmpty { - // 새 이미지만 업로드 - uploadNewImagesForUpdate(store, newImages: newImages) - } else { - // 새 이미지가 없으면 바로 스토어 정보 업데이트 - updateStoreInfo(store, updatedImagePaths: nil) - } + private func updateCategoryButtonTitle(with category: String) { + categoryButton.setTitle("\(category) ▾", for: .normal) } - private func uploadNewImagesForUpdate(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore, newImages: [ExtendedImage]) { - let uuid = UUID().uuidString - let updatedImages = newImages.enumerated().map { index, image in - let filePath = "PopUpImage/\(nameField?.text ?? "")/\(uuid)/\(index).jpg" - return ExtendedImage( - filePath: filePath, - image: image.image, - isMain: image.isMain) - } - - presignedService.tryUpload(datas: updatedImages.map { - PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) - }) - .subscribe( - onSuccess: { [weak self] _ in - guard let self = self else { return } - Logger.log(message: "새 이미지 업로드 성공", category: .info) - - // 모든 이미지 경로 (기존 이미지 + 새 이미지) - var allPaths: [String] = [] - - // 삭제되지 않은 기존 이미지 경로 추가 - let deletedIdSet = Set(self.deletedImageIds) - for (path, id) in self.originalImageIds { - if !deletedIdSet.contains(id) { - allPaths.append(path) - } - } - // 새로 업로드된 이미지 경로 추가 - let newPaths = updatedImages.map { $0.filePath } - allPaths.append(contentsOf: newPaths) - - // 메인 이미지 경로 결정 - let mainImage: String - if let mainImg = self.images.first(where: { $0.isMain }) { - if self.originalImageIds.keys.contains(mainImg.filePath) { - // 기존 이미지가 메인 - mainImage = mainImg.filePath - } else { - // 새 이미지가 메인인 경우, 새 경로 찾기 - let idx = self.images.firstIndex(where: { $0.filePath == mainImg.filePath }) ?? 0 - if idx < updatedImages.count { - mainImage = updatedImages[idx].filePath - } else { - mainImage = updatedImages.first?.filePath ?? "" - } - } - } else if !allPaths.isEmpty { - mainImage = allPaths[0] - } else { - mainImage = "" - } + private func updatePeriodButtonTitle(start: Date, end: Date) { + let df = DateFormatter() + df.dateFormat = "yyyy.MM.dd" + let sStr = df.string(from: start) + let eStr = df.string(from: end) - self.updateStoreInfo(store, updatedImagePaths: allPaths, mainImage: mainImage) - }, - onError: { [weak self] error in - Logger.log(message: "이미지 업로드 실패: \(error.localizedDescription)", category: .error) - self?.showErrorAlert(message: "이미지 업로드 실패: \(error.localizedDescription)") - } - ) - .disposed(by: disposeBag) + periodButton.setTitle("\(sStr) ~ \(eStr)", for: .normal) } - private func uploadImagesForUpdate(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { - let uuid = UUID().uuidString - let updatedImages = images.enumerated().map { index, image in - let filePath = "PopUpImage/\(nameField?.text ?? "")/\(uuid)/\(index).jpg" - return ExtendedImage( - filePath: filePath, - image: image.image, - isMain: image.isMain) - } + private func updateTimeButtonTitle(start: Date, end: Date) { + let df = DateFormatter() + df.dateFormat = "HH:mm" + let stStr = df.string(from: start) + let etStr = df.string(from: end) - presignedService.tryUpload(datas: updatedImages.map { - PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) - }) - .subscribe( - onSuccess: { [weak self] _ in - guard let self = self else { return } - Logger.log(message: "이미지 업로드 성공", category: .info) - let imagePaths = updatedImages.map { $0.filePath } - self.updateStoreInfo(store, updatedImagePaths: imagePaths) - }, - onError: { [weak self] error in - Logger.log(message: "이미지 업로드 실패: \(error.localizedDescription)", category: .error) - self?.showErrorAlert(message: "이미지 업로드 실패: \(error.localizedDescription)") - } - ) - .disposed(by: disposeBag) + timeButton.setTitle("\(stStr) ~ \(etStr)", for: .normal) } - private func updateStoreInfo(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore, updatedImagePaths: [String]?, mainImage: String? = nil) { - guard let name = nameField?.text, - let address = addressField?.text, - let latitude = Double(latField?.text ?? ""), - let longitude = Double(lonField?.text ?? ""), - let description = descTV?.text, - let categoryTitle = categoryButton.title(for: .normal)?.replacingOccurrences(of: " ▾", with: "") else { - return - } - - // 메인 이미지 결정 - let finalMainImage: String - if let mainImagePath = mainImage { - finalMainImage = mainImagePath - } else if let firstImage = updatedImagePaths?.first { - finalMainImage = firstImage - } else { - // 이미지가 없는 경우 기존 메인 이미지 사용 - finalMainImage = store.mainImageUrl - } - - // 이미지 URL 목록 결정 - let imageUrls: [String] - if let paths = updatedImagePaths, !paths.isEmpty { - imageUrls = paths - } else { - // 이미지 변동이 없을 경우 - imageUrls = [store.mainImageUrl] - } - - // 서버에 스토어 정보 업데이트 요청 - let request = UpdatePopUpStoreRequestDTO( - popUpStore: .init( - id: store.id, - name: name, - categoryId: Int64(getCategoryId(from: categoryTitle)), - desc: description, - address: address, - startDate: getFormattedDate(from: selectedStartDate), - endDate: getFormattedDate(from: selectedEndDate), - mainImageUrl: finalMainImage, - bannerYn: !finalMainImage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - imageUrl: imageUrls, - startDateBeforeEndDate: true - ), - location: .init( - latitude: latitude, - longitude: longitude, - markerTitle: "마커 제목", - markerSnippet: "마커 설명" - ), - imagesToAdd: updatedImagePaths?.filter { !originalImageIds.keys.contains($0) } ?? [], - imagesToDelete: deletedImageIds - ) - - // 요청 데이터 로깅 - Logger.log(message: "업데이트 요청 데이터: \(request)", category: .debug) - - adminUseCase.updateStore(request: request) - .subscribe( - onNext: { [weak self] _ in - guard let self = self else { return } - Logger.log(message: "updateStore API 호출 성공", category: .info) - - // 서버 응답이 성공하면 S3에서 이미지 삭제 수행 - self.deleteImagesFromS3() - - // 성공 시 저장된 삭제 이미지 정보 초기화 - if let storeId = self.editingStore?.id { - UserDefaults.standard.removeObject(forKey: "deletedImageIds_\(storeId)") - UserDefaults.standard.removeObject(forKey: "deletedImagePaths_\(storeId)") - Logger.log(message: "삭제된 이미지 정보 영구 저장소에서 제거 완료", category: .debug) - } - - // 메모리 내 삭제 목록도 초기화 - self.deletedImageIds.removeAll() - self.deletedImagePaths.removeAll() - - self.showSuccessAlert(isUpdate: true) - }, - onError: { [weak self] error in - Logger.log(message: "updateStore API 호출 실패: \(error.localizedDescription)", category: .error) - self?.showErrorAlert(message: error.localizedDescription) - } - ) - .disposed(by: disposeBag) - + private func showLoadingIndicator() { + // 로딩 인디케이터 표시 로직 구현 + // 예: Activity Indicator 또는 커스텀 로딩 뷰 표시 } - private func deleteImagesFromS3() { - // 삭제해야 할 이미지가 없으면 바로 리턴 - guard !deletedImagePaths.isEmpty else { return } - - // 모든 이미지 한 번에 삭제 - presignedService.tryDelete(targetPaths: .init(objectKeyList: deletedImagePaths)) - .subscribe( - onCompleted: { - Logger.log(message: "S3에서 모든 이미지 삭제 성공: \(self.deletedImagePaths.count)개", category: .info) - }, - onError: { error in - Logger.log(message: "S3에서 이미지 삭제 실패: \(error.localizedDescription)", category: .error) - } - ) - .disposed(by: disposeBag) + + private func hideLoadingIndicator() { + // 로딩 인디케이터 숨김 로직 구현 } - private func showSuccessAlert(isUpdate: Bool = false) { + private func showSuccessAlert(isUpdate: Bool) { let message = isUpdate ? "팝업스토어가 성공적으로 수정되었습니다." : "팝업스토어가 성공적으로 등록되었습니다." let alert = UIAlertController( title: isUpdate ? "수정 성공" : "등록 성공", @@ -1544,229 +923,112 @@ private extension PopUpStoreRegisterViewController { present(alert, animated: true) } - // 이미지 업로드 - private func uploadImages() { - let uuid = UUID().uuidString - // let baseS3URL = Secrets.popPoolS3BaseURL.rawValue - let updatedImages = images.enumerated().map { index, image in - let filePath = "PopUpImage/\(nameField?.text ?? "")/\(uuid)/\(index).jpg" - return ExtendedImage( - filePath: filePath, - image: image.image, - isMain: image.isMain) - } - - // let presignedService = PreSignedService() - presignedService.tryUpload(datas: updatedImages.map { - PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) - }) - // .observe(on: MainScheduler.instance) - .subscribe( - onSuccess: { [weak self] _ in - guard let self = self else { return } - Logger.log(message: "이미지 업로드 성공", category: .info) - - let imagePaths = updatedImages.map { $0.filePath } - let mainImage = updatedImages.first { $0.isMain }?.filePath ?? "" - self.callCreateStoreAPI(mainImage: mainImage, imagePaths: imagePaths) // baseURL 제거 - }, - onError: { error in - Logger.log(message: "이미지 업로드 실패: \(error.localizedDescription)", category: .error) - self.showErrorAlert(message: "이미지 업로드 실패: \(error.localizedDescription)") - } + private func showErrorAlert(message: String) { + let alert = UIAlertController( + title: "오류", + message: message, + preferredStyle: .alert ) - .disposed(by: disposeBag) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } +} +// MARK: - UICollectionView DataSource & Delegate +extension PopUpStoreRegisterViewController: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return reactor?.currentState.images.count ?? 0 } - // createStore API 호출 - private func callCreateStoreAPI(mainImage: String, imagePaths: [String]) { - guard let name = nameField?.text, - let address = addressField?.text, - let latitude = Double(latField?.text ?? ""), - let longitude = Double(lonField?.text ?? ""), - let description = descTV?.text, - let categoryTitle = categoryButton.title(for: .normal)?.replacingOccurrences(of: " ▾", with: "") else { - Logger.log(message: "필수 입력값이 비어 있음", category: .error) - return + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ImageCell.identifier, + for: indexPath + ) as? ImageCell, + let images = reactor?.currentState.images, + indexPath.item < images.count else { + return UICollectionViewCell() } - let categoryId = getCategoryId(from: categoryTitle) - - Logger.log( - message: """ - 팝업스토어 등록 요청: - - 이름: \(name) - - 카테고리: \(categoryTitle) (ID: \(categoryId)) - - 주소: \(address) - - 위도/경도: (\(latitude), \(longitude)) - - 설명: \(description) - - 시작일: \(getFormattedDate(from: selectedStartDate)) - - 종료일: \(getFormattedDate(from: selectedEndDate)) - - 메인이미지: \(mainImage) - - 전체이미지: \(imagePaths) - """, - category: .network - ) - - let bannerYn = !mainImage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - let dates = prepareDateTime() - let isValidDateOrder = validateDates(start: selectedStartDate, end: selectedEndDate) - - let request = CreatePopUpStoreRequestDTO( - name: name, - categoryId: Int64(categoryId), - desc: description, - address: address, - startDate: dates.startDate, - endDate: dates.endDate, - mainImageUrl: mainImage, - imageUrlList: imagePaths, - latitude: latitude, - longitude: longitude, - markerTitle: "마커 제목", - markerSnippet: "마커 설명", - startDateBeforeEndDate: isValidDateOrder - ) + let item = images[indexPath.item] + cell.configure(with: item) - adminUseCase.createStore(request: request) - .subscribe( - onNext: { [weak self] _ in - Logger.log(message: "createStore API 호출 성공", category: .info) - self?.showSuccessAlert() - }, - onError: { [weak self] error in - Logger.log(message: "createStore API 호출 실패: \(error.localizedDescription)", category: .error) - self?.showErrorAlert(message: error.localizedDescription) - } - ) - .disposed(by: disposeBag) - } - private func getCategoryId(from title: String) -> Int { - Logger.log(message: "카테고리 매핑 시작 - 타이틀: \(title)", category: .debug) - - let categoryMap: [String: Int64] = [ - "패션": 1, - "라이프스타일": 2, - "뷰티": 3, - "음식/요리": 4, - "예술": 5, - "반려동물": 6, - "여행": 7, - "엔터테인먼트": 8, - "애니메이션": 9, - "키즈": 10, - "스포츠": 11, - "게임": 12 - ] - - if let id = categoryMap[title] { - Logger.log(message: "카테고리 매핑 성공: \(title) -> \(id)", category: .debug) - return Int(id) - } else { - Logger.log(message: "카테고리 매핑 실패: \(title)에 해당하는 ID를 찾을 수 없음", category: .error) - return 1 // 기본값 + // 대표이미지 변경 + cell.onMainCheckToggled = { [weak self] in + self?.reactor?.action.onNext(.toggleMainImage(indexPath.item)) } - } - - private func createDateTime(date: Date?, time: Date?) -> Date? { - guard let date = date else { return nil } - - if let time = time { - let calendar = Calendar.current - - // 날짜 부분 추출 - var components = calendar.dateComponents([.year, .month, .day], from: date) - - // 시간 부분 추출 - let timeComponents = calendar.dateComponents([.hour, .minute], from: time) - // 날짜와 시간 결합 - components.hour = timeComponents.hour - components.minute = timeComponents.minute - components.second = 0 - - // 명시적으로 한국 시간대 지정 - components.timeZone = TimeZone(identifier: "Asia/Seoul") - - return calendar.date(from: components) + // 개별 삭제 + cell.onDeleteTapped = { [weak self] in + self?.reactor?.action.onNext(.removeImage(indexPath.item)) } - return date + return cell } +} - private func getFormattedDate(from date: Date?) -> String { - guard let date = date else { return "" } +// MARK: - PHPickerViewController Delegate +extension PopUpStoreRegisterViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { return } - // 한국 시간대를 명시적으로 지정하고 ISO 8601 형식으로 변환 - let formatter = DateFormatter() - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대로 명시 - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + let itemProviders = results.map(\.itemProvider) + let dispatchGroup = DispatchGroup() + var newImages = [ExtendedImage]() - // Z 표기를 추가하지 않음 (시간대 정보 없음) - return formatter.string(from: date) - } + // 이미 로드된 이미지 경로 목록 (중복 방지) + let existingPaths = Set(reactor?.currentState.images.map { $0.filePath } ?? []) - private func prepareDateTime() -> (startDate: String, endDate: String) { - // 시작일/시간 결합 - let startDateTime = createDateTime(date: selectedStartDate, time: selectedStartTime) + for (index, provider) in itemProviders.enumerated() { + if provider.canLoadObject(ofClass: UIImage.self) { + dispatchGroup.enter() + provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + defer { dispatchGroup.leave() } + guard let image = object as? UIImage else { return } - // 종료일/시간 결합 - let endDateTime = createDateTime(date: selectedEndDate, time: selectedEndTime) + let name = self?.reactor?.currentState.name ?? "unnamed" + let uuid = UUID().uuidString + let filePath = "PopUpImage/\(name)/\(uuid)/\(index).jpg" - // 디버그용 로그 - if let startTime = selectedStartTime, let endTime = selectedEndTime { - let timeFormatter = DateFormatter() - timeFormatter.dateFormat = "HH:mm" - Logger.log(message: "선택된 시작 시간: \(timeFormatter.string(from: startTime))", category: .debug) - Logger.log(message: "선택된 종료 시간: \(timeFormatter.string(from: endTime))", category: .debug) + // 이미 같은 경로가 있는지 확인 + if !existingPaths.contains(filePath) { + let extended = ExtendedImage( + filePath: filePath, + image: image, + isMain: false + ) + newImages.append(extended) + } + } + } } - // 결합된 날짜/시간 로깅 - if let start = startDateTime, let end = endDateTime { - let dateTimeFormatter = DateFormatter() - dateTimeFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - Logger.log(message: "결합된 시작 일시: \(dateTimeFormatter.string(from: start))", category: .debug) - Logger.log(message: "결합된 종료 일시: \(dateTimeFormatter.string(from: end))", category: .debug) + dispatchGroup.notify(queue: .main) { [weak self] in + if !newImages.isEmpty { + self?.reactor?.action.onNext(.addImages(newImages)) + } } - - let startDate = getFormattedDate(from: startDateTime) - let endDate = getFormattedDate(from: endDateTime) - - Logger.log(message: "서버로 전송될 시작 일시: \(startDate)", category: .debug) - Logger.log(message: "서버로 전송될 종료 일시: \(endDate)", category: .debug) - - return (startDate: startDate, endDate: endDate) } +} - // 새로운 검증 함수 추가 (prepareDateTime 함수 아래에 추가) - private func validateDates(start: Date?, end: Date?) -> Bool { - guard let start = start, let end = end else { return false } - return start < end +// MARK: - UITextView Delegate +extension PopUpStoreRegisterViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + if textView == descTV { + reactor?.action.onNext(.updateDescription(textView.text)) + } } +} - private func showSuccessAlert() { - let alert = UIAlertController( - title: "등록 성공", - message: "팝업스토어가 성공적으로 등록되었습니다.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default, handler: { [weak self] _ in - // 성공 후 닫기와 핸들러 호출 - self?.completionHandler?() - self?.navigationController?.popViewController(animated: true) - })) - present(alert, animated: true) - } - private func showErrorAlert(message: String) { - let alert = UIAlertController( - title: "등록 실패", - message: message, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - present(alert, animated: true) +// MARK: - Helper Extensions +extension UITextField { + func setLeftPaddingPoints(_ amount: CGFloat) { + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: amount, height: frame.size.height)) + leftView = paddingView + leftViewMode = .always } } + extension UIView { func findFirstResponder() -> UIView? { if isFirstResponder { @@ -1782,78 +1044,3 @@ extension UIView { return nil } } -extension PopUpStoreRegisterViewController: UITextFieldDelegate { - private func setupAddressField() { - // RxCocoa를 사용한 텍스트 필드 바인딩 - addressField?.rx.text.orEmpty - .distinctUntilChanged() - .debounce(.milliseconds(500), scheduler: MainScheduler.instance) - .filter { !$0.isEmpty } - .flatMapLatest { [weak self] address -> Observable in - return Observable.create { observer in - let geocoder = CLGeocoder() - let fullAddress = "\(address), Korea" - - geocoder.geocodeAddressString( - fullAddress, - in: nil, - preferredLocale: Locale(identifier: "ko_KR") - ) { placemarks, error in - if let error = error { - print("Geocoding error: \(error.localizedDescription)") - observer.onNext(nil) - observer.onCompleted() - return - } - - if let location = placemarks?.first?.location { - observer.onNext(location) - } else { - observer.onNext(nil) - } - observer.onCompleted() - } - - return Disposables.create() - } - } - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] location in - guard let location = location else { return } - self?.latField?.text = String(format: "%.6f", location.coordinate.latitude) - self?.lonField?.text = String(format: "%.6f", location.coordinate.longitude) - self?.updateSaveButtonState() - }) - .disposed(by: disposeBag) - } - - @objc private func addressFieldDidChange(_ textField: UITextField) { - guard let address = textField.text, !address.isEmpty else { return } - - // 한국 주소임을 명시 - let geocoder = CLGeocoder() - let addressWithCountry = address + ", South Korea" - - geocoder.geocodeAddressString(addressWithCountry) { [weak self] placemarks, error in - if let error = error { - print("Geocoding error: \(error.localizedDescription)") - return - } - - guard let location = placemarks?.first?.location else { return } - - DispatchQueue.main.async { - self?.latField?.text = String(format: "%.6f", location.coordinate.latitude) - self?.lonField?.text = String(format: "%.6f", location.coordinate.longitude) - self?.updateSaveButtonState() - } - } - } - -} -extension PopUpStoreRegisterViewController: UITextViewDelegate { - func textViewDidChange(_ textView: UITextView) { - Logger.log(message: "설명 필드 값 변경: \(textView.text.count) 글자", category: .debug) - updateSaveButtonState() - } -} From 29738f810413fa3bf484e9a948fed3ce4bd1eb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 11 Apr 2025 11:50:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor/#102:=20RegisterVC=20View=EC=99=80?= =?UTF-8?q?=20PopUpImagesColletionView=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 12 +- .../PopUpImagesCollectionView.swift | 97 ++ .../PopUpStoreRegisterReactor.swift | 90 +- .../PopUpStoreRegisterView.swift | 547 ++++++--- .../PopUpStoreRegisterViewController.swift | 1081 +++++------------ 5 files changed, 888 insertions(+), 939 deletions(-) create mode 100644 Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpImagesCollectionView.swift diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index 22a67e02..8227f3a4 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -429,6 +429,7 @@ 4EDE57032D5E70650014D924 /* LocationPermissionBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDE57022D5E70650014D924 /* LocationPermissionBottomSheet.swift */; }; 4EE360FD2D91876300D2441D /* NMapsMap in Frameworks */ = {isa = PBXBuildFile; productRef = 4EE360FC2D91876300D2441D /* NMapsMap */; }; 4EE5A3D32D40E4A600A2469A /* MapGuideReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5A3D22D40E4A600A2469A /* MapGuideReactor.swift */; }; + 4EEA13072DA7CDDA00775256 /* PopUpImagesCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA13062DA7CDDA00775256 /* PopUpImagesCollectionView.swift */; }; 4EEA1D8F2D352012003E7DE9 /* ImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */; }; 4EEA1D912D352027003E7DE9 /* ExtendedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */; }; 4EECA3942D56770B00A07CCA /* MapPopUpStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685EBB2D12CEB6001EF91C /* MapPopUpStore.swift */; }; @@ -891,6 +892,7 @@ 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimePickerManager.swift; sourceTree = ""; }; 4EDE57022D5E70650014D924 /* LocationPermissionBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionBottomSheet.swift; sourceTree = ""; }; 4EE5A3D22D40E4A600A2469A /* MapGuideReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapGuideReactor.swift; sourceTree = ""; }; + 4EEA13062DA7CDDA00775256 /* PopUpImagesCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpImagesCollectionView.swift; sourceTree = ""; }; 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCell.swift; sourceTree = ""; }; 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedImage.swift; sourceTree = ""; }; 4EED9BAB2D22730400B288E7 /* FilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterType.swift; sourceTree = ""; }; @@ -2736,6 +2738,7 @@ 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */, 4E643FC02D738D7F0046AF29 /* PopUpStoreRegisterReactor.swift */, 4E643FC22D738D930046AF29 /* PopUpStoreRegisterView.swift */, + 4EEA13062DA7CDDA00775256 /* PopUpImagesCollectionView.swift */, ); path = AdminRegister; sourceTree = ""; @@ -3165,6 +3168,7 @@ 086DD9362D00963900B97D3B /* SearchTitleSection.swift in Sources */, 086F89D92D1E79E200CA4FC9 /* GetOtherUserCommentListRequestDTO.swift in Sources */, 083C864F2D0DD3A6003F441C /* AddCommentImageSection.swift in Sources */, + 4EEA13072DA7CDDA00775256 /* PopUpImagesCollectionView.swift in Sources */, 08B191372CF366680057BC04 /* UICollectionViewCell+.swift in Sources */, 083A25B42CF362670099B58E /* Responsable.swift in Sources */, 08CBEA0D2D38ED0D00248007 /* CountButtonView.swift in Sources */, @@ -3706,7 +3710,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Poppool/Poppool.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -3732,7 +3736,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.poppoolIOS.poppool; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = PoppoolGitHubAction; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = poppoolProvisioningProfile; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -3751,7 +3755,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Poppool/Poppool.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; @@ -3777,7 +3781,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.poppoolIOS.poppool; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = PoppoolGitHubAction; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = poppoolProvisioningProfile; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpImagesCollectionView.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpImagesCollectionView.swift new file mode 100644 index 00000000..e36be825 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpImagesCollectionView.swift @@ -0,0 +1,97 @@ +import UIKit + +import SnapKit + +final class PopUpImagesCollectionView: UICollectionView { + // MARK: - Properties + enum Constant { + static let imageWidth: CGFloat = 80 + static let imageHeight: CGFloat = 120 + static let imageSpacing: CGFloat = 8 + } + + var onImageSelected: ((Int) -> Void)? + var onMainImageToggled: ((Int) -> Void)? + var onImageDeleted: ((Int) -> Void)? + + private var images: [ExtendedImage] = [] + + // MARK: - init + init() { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: Constant.imageWidth, height: Constant.imageHeight) + layout.minimumLineSpacing = Constant.imageSpacing + + super.init(frame: .zero, collectionViewLayout: layout) + + self.addViews() + self.setupContstraints() + self.configureUI() + } + + required init?(coder: NSCoder) { + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - Setup +private extension PopUpImagesCollectionView { + func addViews() { } + + func setupContstraints() { } + + func configureUI() { + self.backgroundColor = .clear + self.register(ImageCell.self, forCellWithReuseIdentifier: ImageCell.identifier) + self.dataSource = self + self.delegate = self + self.showsHorizontalScrollIndicator = false + } +} + +// MARK: - Public Methods +extension PopUpImagesCollectionView { + func updateImages(_ images: [ExtendedImage]) { + self.images = images + self.reloadData() + } +} + +// MARK: - UICollectionViewDataSource +extension PopUpImagesCollectionView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return self.images.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ImageCell.identifier, + for: indexPath + ) as? ImageCell else { + return UICollectionViewCell() + } + + let item = self.images[indexPath.item] + cell.configure(with: item) + + // 대표이미지 변경 + cell.onMainCheckToggled = { [weak self] in + self?.onMainImageToggled?(indexPath.item) + } + + // 개별 삭제 + cell.onDeleteTapped = { [weak self] in + self?.onImageDeleted?(indexPath.item) + } + + return cell + } +} + +// MARK: - UICollectionViewDelegate +extension PopUpImagesCollectionView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + self.onImageSelected?(indexPath.item) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift index 7b5e4539..124408ac 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift @@ -15,6 +15,9 @@ final class PopUpStoreRegisterReactor: Reactor { private let isEditMode: Bool private let editingStoreId: Int64? + private var disposeBag = DisposeBag() + + init(adminUseCase: AdminUseCase, presignedService: PreSignedService, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { self.adminUseCase = adminUseCase self.presignedService = presignedService @@ -142,6 +145,8 @@ final class PopUpStoreRegisterReactor: Reactor { case let .updateName(name): return .just(.setName(name)) + + case let .updateAddress(address): return .just(.setAddress(address)) @@ -396,24 +401,76 @@ final class PopUpStoreRegisterReactor: Reactor { // 폼 유효성 검사 private func validateForm(state: State) -> Bool { - // 필수 필드 검사 - guard !state.name.isEmpty, - !state.address.isEmpty, - !state.lat.isEmpty, - !state.lon.isEmpty, - !state.description.isEmpty, - !state.category.isEmpty, - !state.images.isEmpty, - state.images.contains(where: { $0.isMain }), - state.selectedStartDate != nil, - state.selectedEndDate != nil else { + Logger.log(message: "폼 유효성 검사 시작", category: .debug) + + // 이름 필드 검사 + guard !state.name.isEmpty else { + Logger.log(message: "유효성 검사 실패: 이름 비어있음", category: .debug) + return false + } + + // 주소 필드 검사 + guard !state.address.isEmpty else { + Logger.log(message: "유효성 검사 실패: 주소 비어있음", category: .debug) + return false + } + + // 위도/경도 필드 검사 + guard !state.lat.isEmpty else { + Logger.log(message: "유효성 검사 실패: 위도 비어있음", category: .debug) + return false + } + + guard !state.lon.isEmpty else { + Logger.log(message: "유효성 검사 실패: 경도 비어있음", category: .debug) + return false + } + + // 설명 필드 검사 + guard !state.description.isEmpty else { + Logger.log(message: "유효성 검사 실패: 설명 비어있음", category: .debug) + return false + } + + // 카테고리 필드 검사 + guard !state.category.isEmpty else { + Logger.log(message: "유효성 검사 실패: 카테고리 비어있음", category: .debug) + return false + } + + // 이미지 검사 + guard !state.images.isEmpty else { + Logger.log(message: "유효성 검사 실패: 이미지 없음", category: .debug) + return false + } + + // 대표 이미지 검사 + guard state.images.contains(where: { $0.isMain }) else { + Logger.log(message: "유효성 검사 실패: 대표 이미지 없음", category: .debug) + return false + } + + // 날짜 검사 + guard state.selectedStartDate != nil else { + Logger.log(message: "유효성 검사 실패: 시작 날짜 없음", category: .debug) + return false + } + + guard state.selectedEndDate != nil else { + Logger.log(message: "유효성 검사 실패: 종료 날짜 없음", category: .debug) return false } // 위도/경도 유효성 검사 guard let latVal = Double(state.lat), - let lonVal = Double(state.lon), - latVal != 0 || lonVal != 0 else { + let lonVal = Double(state.lon) else { + Logger.log(message: "유효성 검사 실패: 위도/경도 형식 오류", category: .debug) + return false + } + + // 위도/경도 값이 유효한지 검사 + guard latVal != 0 || lonVal != 0 else { + Logger.log(message: "유효성 검사 실패: 위도/경도 값이 모두 0", category: .debug) return false } @@ -421,14 +478,19 @@ final class PopUpStoreRegisterReactor: Reactor { if let startDate = state.selectedStartDate, let endDate = state.selectedEndDate, startDate > endDate { + Logger.log(message: "유효성 검사 실패: 시작일이 종료일보다 늦음", category: .debug) return false } + Logger.log(message: "유효성 검사 성공", category: .debug) return true } + // 주소 지오코딩 private func geocodeAddress(address: String) -> Observable { + Logger.log(message: "지오코딩 함수 호출: \(address)", category: .debug) + return Observable.create { observer in let geocoder = CLGeocoder() let fullAddress = "\(address), Korea" @@ -470,7 +532,7 @@ final class PopUpStoreRegisterReactor: Reactor { Logger.log(message: "S3에서 이미지 삭제 실패: \(error.localizedDescription)", category: .error) } ) - .disposed(by: DisposeBag()) + .disposed(by: disposeBag) } // 카테고리 ID 매핑 diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift index 5b4b04fc..d523fc06 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift @@ -1,15 +1,29 @@ -import RxCocoa -import RxSwift -import SnapKit import UIKit -final class PopUpStoreRegisterView: UIView { - // 상단 네비게이션 영역 +import SnapKit + +final class PopUpRegisterView: UIView { + // MARK: - Properties + + enum Constant { + static let navigationHeight: CGFloat = 44 + static let logoWidth: CGFloat = 22 + static let logoHeight: CGFloat = 35 + static let edgeInset: CGFloat = 16 + static let buttonSize: CGFloat = 32 + static let cornerRadius: CGFloat = 8 + static let verticalSpacing: CGFloat = 8 + static let formLabelWidth: CGFloat = 80 + } + + // 네비게이션 영역 + let navigationContainer = UIView() + let logoImageView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "image_login_logo") - iv.contentMode = .scaleAspectFit - return iv + let imageView = UIImageView() + imageView.image = UIImage(named: "image_login_logo") + imageView.contentMode = .scaleAspectFit + return imageView }() let accountIdLabel: UILabel = { @@ -27,6 +41,8 @@ final class PopUpStoreRegisterView: UIView { }() // 타이틀 영역 + let titleContainer = UIView() + let backButton: UIButton = { let btn = UIButton(type: .system) btn.setImage(UIImage(systemName: "chevron.left"), for: .normal) @@ -42,14 +58,47 @@ final class PopUpStoreRegisterView: UIView { return lbl }() - // 입력 폼 영역 - let nameTextField: UITextField = { - let tf = UITextField() - tf.placeholder = "팝업스토어 이름을 입력해 주세요." - tf.font = UIFont.systemFont(ofSize: 14) - tf.borderStyle = .roundedRect - return tf - }() + // 스크롤 영역 + let scrollView = UIScrollView() + let contentView = UIView() + + // 이미지 영역 + let addImageButton = UIButton(type: .system).then { + $0.setTitle("이미지 추가", for: .normal) + $0.setTitleColor(.systemBlue, for: .normal) + } + + let removeAllButton = UIButton(type: .system).then { + $0.setTitle("전체 삭제", for: .normal) + $0.setTitleColor(.red, for: .normal) + } + + let imagesCollectionView: PopUpImagesCollectionView = PopUpImagesCollectionView() + + // 폼 영역 + let formBackgroundView = UIView().then { + $0.backgroundColor = .white + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.lightGray.cgColor + $0.layer.cornerRadius = 8 + } + + let verticalStack = UIStackView().then { + $0.axis = .vertical + $0.spacing = 0 + } + + // 폼 필드들 + let nameField = UITextField().then { + $0.placeholder = "팝업스토어 이름을 입력해 주세요." + $0.font = UIFont.systemFont(ofSize: 14) + $0.textColor = .darkGray + $0.borderStyle = .none + $0.layer.cornerRadius = 8 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.lightGray.cgColor + $0.setLeftPaddingPoints(8) + } let categoryButton: UIButton = { let btn = UIButton(type: .system) @@ -64,68 +113,76 @@ final class PopUpStoreRegisterView: UIView { return btn }() - let addressTextField: UITextField = { - let tf = UITextField() - tf.placeholder = "팝업스토어 주소를 입력해 주세요." - tf.font = UIFont.systemFont(ofSize: 14) - tf.borderStyle = .roundedRect - return tf - }() - - let latTextField: UITextField = { - let tf = UITextField() - tf.placeholder = "위도" - tf.font = UIFont.systemFont(ofSize: 14) - tf.textAlignment = .center - tf.borderStyle = .roundedRect - return tf - }() + let addressField = UITextField().then { + $0.placeholder = "팝업스토어 주소를 입력해 주세요." + $0.font = UIFont.systemFont(ofSize: 14) + $0.textColor = .darkGray + $0.borderStyle = .none + $0.layer.cornerRadius = 8 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.lightGray.cgColor + $0.setLeftPaddingPoints(8) + } - let lonTextField: UITextField = { - let tf = UITextField() - tf.placeholder = "경도" - tf.font = UIFont.systemFont(ofSize: 14) - tf.textAlignment = .center - tf.borderStyle = .roundedRect - return tf - }() + let latField = UITextField().then { + $0.placeholder = "" + $0.font = UIFont.systemFont(ofSize: 14) + $0.textColor = .darkGray + $0.borderStyle = .none + $0.layer.cornerRadius = 8 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.lightGray.cgColor + $0.textAlignment = .center + $0.setLeftPaddingPoints(8) + } - let descriptionTextView: UITextView = { - let tv = UITextView() - tv.font = UIFont.systemFont(ofSize: 14) - tv.layer.cornerRadius = 8 - tv.layer.borderWidth = 1 - tv.layer.borderColor = UIColor.lightGray.cgColor - tv.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) - return tv - }() + let lonField = UITextField().then { + $0.placeholder = "" + $0.font = UIFont.systemFont(ofSize: 14) + $0.textColor = .darkGray + $0.borderStyle = .none + $0.layer.cornerRadius = 8 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.lightGray.cgColor + $0.textAlignment = .center + $0.setLeftPaddingPoints(8) + } - // 이미지 관련 영역 - let addImageButton: UIButton = { + let periodButton: UIButton = { let btn = UIButton(type: .system) - btn.setTitle("이미지 추가", for: .normal) - btn.setTitleColor(.systemBlue, for: .normal) + btn.setTitle("기간 선택 ▾", for: .normal) + btn.setTitleColor(.darkGray, for: .normal) + btn.titleLabel?.font = UIFont.systemFont(ofSize: 14) + btn.layer.cornerRadius = 8 + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor.lightGray.cgColor + btn.contentHorizontalAlignment = .left + btn.contentEdgeInsets = UIEdgeInsets(top: 7, left: 8, bottom: 7, right: 8) return btn }() - let removeAllButton: UIButton = { + let timeButton: UIButton = { let btn = UIButton(type: .system) - btn.setTitle("전체 삭제", for: .normal) - btn.setTitleColor(.red, for: .normal) + btn.setTitle("시간 선택 ▾", for: .normal) + btn.setTitleColor(.darkGray, for: .normal) + btn.titleLabel?.font = UIFont.systemFont(ofSize: 14) + btn.layer.cornerRadius = 8 + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor.lightGray.cgColor + btn.contentHorizontalAlignment = .left + btn.contentEdgeInsets = UIEdgeInsets(top: 7, left: 8, bottom: 7, right: 8) return btn }() - lazy var imagesCollectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.itemSize = CGSize(width: 80, height: 120) - layout.minimumLineSpacing = 8 - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - // 셀 등록 등은 실제 프로젝트에 맞게 설정합니다. - cv.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "ImageCell") - return cv - }() + let descriptionTextView = UITextView().then { + $0.font = UIFont.systemFont(ofSize: 14) + $0.textColor = .darkGray + $0.layer.cornerRadius = 8 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.lightGray.cgColor + $0.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) + $0.isScrollEnabled = true + } // 저장 버튼 let saveButton: UIButton = { @@ -139,127 +196,333 @@ final class PopUpStoreRegisterView: UIView { return btn }() - // 기타 UI 요소는 필요에 따라 추가하세요. + // MARK: - init + init() { + super.init(frame: .zero) - // MARK: - Initializer - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = UIColor(white: 0.95, alpha: 1) - setupLayout() + self.addViews() + self.setupContstraints() + self.configureUI() } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + fatalError("\(#file), \(#function) Error") + } +} + +// MARK: - SetUp +private extension PopUpRegisterView { + func addViews() { + // 네비게이션 영역 + self.addSubview(self.navigationContainer) + self.navigationContainer.addSubview(self.logoImageView) + self.navigationContainer.addSubview(self.accountIdLabel) + self.navigationContainer.addSubview(self.menuButton) + + // 타이틀 영역 + self.addSubview(self.titleContainer) + self.titleContainer.addSubview(self.backButton) + self.titleContainer.addSubview(self.pageTitleLabel) + + // 스크롤 영역 + self.addSubview(self.scrollView) + self.scrollView.addSubview(self.contentView) + + // 이미지 영역 + let buttonStack = UIStackView(arrangedSubviews: [self.addImageButton, self.removeAllButton]) + buttonStack.axis = .horizontal + buttonStack.distribution = .fillEqually + buttonStack.spacing = 16 + + self.contentView.addSubview(buttonStack) + self.contentView.addSubview(self.imagesCollectionView) + + // 폼 영역 + self.contentView.addSubview(self.formBackgroundView) + self.formBackgroundView.addSubview(self.verticalStack) + + // 저장 버튼 + self.addSubview(self.saveButton) } - // MARK: - Layout Setup - private func setupLayout() { - // 예시로 상단 네비게이션과 입력폼 일부만 배치합니다. - let navContainer = UIView() - addSubview(navContainer) - navContainer.snp.makeConstraints { make in - make.top.equalTo(self.safeAreaLayoutGuide.snp.top) + func setupContstraints() { + // 네비게이션 영역 + self.navigationContainer.snp.makeConstraints { make in + make.top.equalTo(self.safeAreaLayoutGuide) make.left.right.equalToSuperview() - make.height.equalTo(44) + make.height.equalTo(Constant.navigationHeight) } - navContainer.addSubview(logoImageView) - logoImageView.snp.makeConstraints { make in + self.logoImageView.snp.makeConstraints { make in make.left.equalToSuperview().offset(8) make.centerY.equalToSuperview() - make.width.equalTo(22) - make.height.equalTo(35) + make.width.equalTo(Constant.logoWidth) + make.height.equalTo(Constant.logoHeight) } - navContainer.addSubview(accountIdLabel) - accountIdLabel.snp.makeConstraints { make in + self.accountIdLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() - make.left.equalTo(logoImageView.snp.right).offset(8) + make.left.equalTo(self.logoImageView.snp.right).offset(8) } - navContainer.addSubview(menuButton) - menuButton.snp.makeConstraints { make in + self.menuButton.snp.makeConstraints { make in make.centerY.equalToSuperview() - make.right.equalToSuperview().inset(16) - make.width.height.equalTo(32) + make.right.equalToSuperview().inset(Constant.edgeInset) + make.width.height.equalTo(Constant.buttonSize) } // 타이틀 영역 - let titleContainer = UIView() - addSubview(titleContainer) - titleContainer.snp.makeConstraints { make in - make.top.equalTo(navContainer.snp.bottom) + self.titleContainer.snp.makeConstraints { make in + make.top.equalTo(self.navigationContainer.snp.bottom) make.left.right.equalToSuperview() - make.height.equalTo(44) + make.height.equalTo(Constant.navigationHeight) } - titleContainer.addSubview(backButton) - backButton.snp.makeConstraints { make in + self.backButton.snp.makeConstraints { make in make.centerY.equalToSuperview() make.left.equalToSuperview().offset(8) make.width.height.equalTo(32) } - titleContainer.addSubview(pageTitleLabel) - pageTitleLabel.snp.makeConstraints { make in + self.pageTitleLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() - make.left.equalTo(backButton.snp.right).offset(4) + make.left.equalTo(self.backButton.snp.right).offset(4) } - // 입력폼 영역 (예시) - let formStack = UIStackView(arrangedSubviews: [nameTextField, categoryButton, addressTextField]) - formStack.axis = .vertical - formStack.spacing = 16 - addSubview(formStack) - formStack.snp.makeConstraints { make in - make.top.equalTo(titleContainer.snp.bottom).offset(16) - make.left.right.equalToSuperview().inset(16) + // 스크롤 영역 + self.scrollView.snp.makeConstraints { make in + make.top.equalTo(self.titleContainer.snp.bottom) + make.left.right.equalToSuperview() + make.bottom.equalTo(self.safeAreaLayoutGuide).offset(-74) } - // 위/경도는 수평 스택 - let latLonStack = UIStackView(arrangedSubviews: [latTextField, lonTextField]) - latLonStack.axis = .horizontal - latLonStack.spacing = 16 - latLonStack.distribution = .fillEqually - addSubview(latLonStack) - latLonStack.snp.makeConstraints { make in - make.top.equalTo(formStack.snp.bottom).offset(16) - make.left.right.equalToSuperview().inset(16) + self.contentView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(self.scrollView.snp.width) } - // 설명 텍스트뷰 - addSubview(descriptionTextView) - descriptionTextView.snp.makeConstraints { make in - make.top.equalTo(latLonStack.snp.bottom).offset(16) + // 이미지 영역 + let buttonStack = self.contentView.subviews.first as! UIStackView + buttonStack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) make.left.right.equalToSuperview().inset(16) - make.height.equalTo(120) + make.height.equalTo(40) } - // 이미지 영역 (Add/Remove 버튼과 CollectionView) - let imageButtonStack = UIStackView(arrangedSubviews: [addImageButton, removeAllButton]) - imageButtonStack.axis = .horizontal - imageButtonStack.distribution = .fillEqually - imageButtonStack.spacing = 16 - addSubview(imageButtonStack) - imageButtonStack.snp.makeConstraints { make in - make.top.equalTo(descriptionTextView.snp.bottom).offset(16) + self.imagesCollectionView.snp.makeConstraints { make in + make.top.equalTo(buttonStack.snp.bottom).offset(8) make.left.right.equalToSuperview().inset(16) - make.height.equalTo(40) + make.height.equalTo(130) } - addSubview(imagesCollectionView) - imagesCollectionView.snp.makeConstraints { make in - make.top.equalTo(imageButtonStack.snp.bottom).offset(8) + // 폼 영역 + self.formBackgroundView.snp.makeConstraints { make in + make.top.equalTo(self.imagesCollectionView.snp.bottom).offset(16) make.left.right.equalToSuperview().inset(16) - make.height.equalTo(130) + make.bottom.equalToSuperview().offset(-16) + } + + self.verticalStack.snp.makeConstraints { make in + make.edges.equalToSuperview() } // 저장 버튼 - addSubview(saveButton) - saveButton.snp.makeConstraints { make in + self.saveButton.snp.makeConstraints { make in make.left.right.equalToSuperview().inset(16) - make.bottom.equalTo(self.safeAreaLayoutGuide.snp.bottom).offset(-16) + make.bottom.equalTo(self.safeAreaLayoutGuide).offset(-16) make.height.equalTo(44) } } + + func configureUI() { + self.backgroundColor = UIColor(white: 0.95, alpha: 1) + + // 폼 요소 추가 + self.setupFormRows() + } + + func setupFormRows() { + // 이름 필드 추가 + self.addFormRow(leftTitle: "이름", rightView: self.nameField) + + // 카테고리 버튼 추가 + self.addFormRow(leftTitle: "카테고리", rightView: self.categoryButton) + + // 위치 필드 추가 (주소, 위도, 경도) + let latLabel = self.makePlainLabel("위도") + let lonLabel = self.makePlainLabel("경도") + + let latStack = UIStackView(arrangedSubviews: [latLabel, self.latField]) + latStack.axis = .horizontal + latStack.spacing = 8 + latStack.distribution = .fillProportionally + + let lonStack = UIStackView(arrangedSubviews: [lonLabel, self.lonField]) + lonStack.axis = .horizontal + lonStack.spacing = 8 + lonStack.distribution = .fillProportionally + + let latLonRow = UIStackView(arrangedSubviews: [latStack, lonStack]) + latLonRow.axis = .horizontal + latLonRow.spacing = 16 + latLonRow.distribution = .fillEqually + + let locationVStack = UIStackView(arrangedSubviews: [self.addressField, latLonRow]) + locationVStack.axis = .vertical + locationVStack.spacing = 8 + locationVStack.distribution = .fillEqually + + self.addFormRow(leftTitle: "위치", rightView: locationVStack, rowHeight: nil, totalHeight: 80) + + // 마커 필드 추가 + let markerLabel = self.makePlainLabel("마커명") + let markerField = self.makeRoundedTextField("") + let markerStackH = UIStackView(arrangedSubviews: [markerLabel, markerField]) + markerStackH.axis = .horizontal + markerStackH.spacing = 8 + markerStackH.distribution = .fillProportionally + + let snippetLabel = self.makePlainLabel("스니펫") + let snippetField = self.makeRoundedTextField("") + let snippetStackH = UIStackView(arrangedSubviews: [snippetLabel, snippetField]) + snippetStackH.axis = .horizontal + snippetStackH.spacing = 8 + snippetStackH.distribution = .fillProportionally + + let markerVStack = UIStackView(arrangedSubviews: [markerStackH, snippetStackH]) + markerVStack.axis = .vertical + markerVStack.spacing = 8 + markerVStack.distribution = .fillEqually + + self.addFormRow(leftTitle: "마커", rightView: markerVStack, rowHeight: nil, totalHeight: 80) + + // 기간 및 시간 + self.addFormRow(leftTitle: "기간", rightView: self.periodButton) + self.addFormRow(leftTitle: "시간", rightView: self.timeButton) + + // 작성자 및 작성시간 + let currentTime = self.getCurrentFormattedTime() + let writerLbl = self.makeSimpleLabel("") + let timeLbl = self.makeSimpleLabel(currentTime) + + self.addFormRow(leftTitle: "작성자", rightView: writerLbl) + self.addFormRow(leftTitle: "작성시간", rightView: timeLbl) + + // 상태값 + let statusLbl = self.makeSimpleLabel("진행") + self.addFormRow(leftTitle: "상태값", rightView: statusLbl) + + // 설명 + self.addFormRow(leftTitle: "설명", rightView: self.descriptionTextView, rowHeight: nil, totalHeight: 120) + } + + // 폼 행 추가 헬퍼 메서드 + func addFormRow(leftTitle: String, rightView: UIView, rowHeight: CGFloat? = 36, totalHeight: CGFloat? = nil) { + let row = UIView() + row.backgroundColor = .white + + let leftBG = UIView() + leftBG.backgroundColor = UIColor(white: 0.94, alpha: 1) + row.addSubview(leftBG) + leftBG.snp.makeConstraints { make in + make.top.bottom.left.equalToSuperview() + make.width.equalTo(80) + } + + let leftLabel = UILabel() + leftLabel.text = leftTitle + leftLabel.font = UIFont.systemFont(ofSize: 15, weight: .bold) + leftLabel.textColor = .black + leftLabel.textAlignment = .center + leftBG.addSubview(leftLabel) + leftLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.left.right.equalToSuperview().inset(8) + } + + let rightBG = UIView() + rightBG.backgroundColor = .white + row.addSubview(rightBG) + rightBG.snp.makeConstraints { make in + make.top.bottom.right.equalToSuperview() + make.left.equalTo(leftBG.snp.right) + } + + rightBG.addSubview(rightView) + rightView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(7) + make.bottom.equalToSuperview().offset(-7) + make.left.equalToSuperview().offset(8) + make.right.equalToSuperview().offset(-8) + if let fixH = rowHeight { + make.height.equalTo(fixH).priority(.medium) + } + } + + if let totalH = totalHeight { + row.snp.makeConstraints { make in + make.height.equalTo(totalH).priority(.high) + } + } else { + row.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(41) + } + } + + let separator = UIView() + separator.backgroundColor = UIColor.lightGray.withAlphaComponent(0.3) + row.addSubview(separator) + separator.snp.makeConstraints { make in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(1) + } + + self.verticalStack.addArrangedSubview(row) + } + + // 유틸리티 메서드 + func makeRoundedTextField(_ placeholder: String) -> UITextField { + let tf = UITextField() + tf.placeholder = placeholder + tf.font = UIFont.systemFont(ofSize: 14) + tf.textColor = .darkGray + tf.borderStyle = .none + tf.layer.cornerRadius = 8 + tf.layer.borderWidth = 1 + tf.layer.borderColor = UIColor.lightGray.cgColor + tf.setLeftPaddingPoints(8) + return tf + } + + func makePlainLabel(_ text: String) -> UILabel { + let lbl = UILabel() + lbl.text = text + lbl.font = UIFont.systemFont(ofSize: 14) + lbl.textColor = .darkGray + lbl.textAlignment = .right + lbl.setContentHuggingPriority(.required, for: .horizontal) + return lbl + } + + func makeSimpleLabel(_ text: String) -> UILabel { + let lbl = UILabel() + lbl.text = text + lbl.font = UIFont.systemFont(ofSize: 14) + lbl.textColor = .darkGray + return lbl + } + + func getCurrentFormattedTime() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd HH:mm" + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + return formatter.string(from: Date()) + } +} +extension UITextField { + func setLeftPaddingPoints(_ amount: CGFloat) { + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: amount, height: frame.size.height)) + leftView = paddingView + leftViewMode = .always + } } diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift index ac9dd346..ff17f694 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift @@ -1,177 +1,28 @@ +import UIKit import CoreLocation import PhotosUI -import UIKit -import Alamofire -import ReactorKit +import SnapKit import RxCocoa import RxSwift -import SnapKit -import Then +import ReactorKit -final class PopUpStoreRegisterViewController: BaseViewController, View { - typealias Reactor = PopUpStoreRegisterReactor +final class PopUpStoreRegisterViewController: BaseViewController { // MARK: - Properties - var disposeBag = DisposeBag() private var pickerViewController: PHPickerViewController? private let adminUseCase: AdminUseCase - private var nameField: UITextField? - private var addressField: UITextField? - private var latField: UITextField? - private var lonField: UITextField? - private var descTV: UITextView? - var completionHandler: (() -> Void)? private let nickname: String + var completionHandler: (() -> Void)? + var disposeBag = DisposeBag() - // MARK: - UI Components - private let navContainer = UIView() - - private lazy var imagesCollectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.itemSize = CGSize(width: 80, height: 120) - layout.minimumLineSpacing = 8 - - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.register(ImageCell.self, forCellWithReuseIdentifier: ImageCell.identifier) - cv.dataSource = self - cv.delegate = self - return cv - }() - - private let logoImageView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "image_login_logo") - iv.contentMode = .scaleAspectFit - return iv - }() - - private let accountIdLabel: UILabel = { - let lbl = UILabel() - lbl.font = UIFont.systemFont(ofSize: 14, weight: .semibold) - lbl.textColor = .black - return lbl - }() - - private let menuButton: UIButton = { - let btn = UIButton(type: .system) - btn.setImage(UIImage(systemName: "adminlist"), for: .normal) - btn.tintColor = .black - return btn - }() - - private let titleContainer = UIView() - private let backButton: UIButton = { - let btn = UIButton(type: .system) - btn.setImage(UIImage(systemName: "chevron.left"), for: .normal) - btn.tintColor = .black - return btn - }() - - private let pageTitleLabel: UILabel = { - let lbl = UILabel() - lbl.text = "팝업스토어 등록" - lbl.font = UIFont.boldSystemFont(ofSize: 18) - lbl.textColor = .black - return lbl - }() - - private let addImageButton = UIButton(type: .system).then { - $0.setTitle("이미지 추가", for: .normal) - $0.setTitleColor(.systemBlue, for: .normal) - } - - private let removeAllButton = UIButton(type: .system).then { - $0.setTitle("전체 삭제", for: .normal) - $0.setTitleColor(.red, for: .normal) - } - - private let scrollView = UIScrollView() - private let contentView = UIView() - - private let formBackgroundView = UIView().then { - $0.backgroundColor = .white - $0.layer.borderWidth = 1 - $0.layer.borderColor = UIColor.lightGray.cgColor - $0.layer.cornerRadius = 8 - } - - private let verticalStack = UIStackView() - - private let saveButton: UIButton = { - let btn = UIButton(type: .system) - btn.setTitle("저장", for: .normal) - btn.setTitleColor(.white, for: .normal) - btn.backgroundColor = .lightGray - btn.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .bold) - btn.layer.cornerRadius = 8 - btn.isEnabled = false - return btn - }() - - private let categoryButton: UIButton = { - let btn = UIButton(type: .system) - btn.setTitle("카테고리 선택 ▾", for: .normal) - btn.setTitleColor(.darkGray, for: .normal) - btn.titleLabel?.font = UIFont.systemFont(ofSize: 14) - btn.layer.cornerRadius = 8 - btn.layer.borderWidth = 1 - btn.layer.borderColor = UIColor.lightGray.cgColor - btn.contentHorizontalAlignment = .left - btn.contentEdgeInsets = UIEdgeInsets(top: 7, left: 8, bottom: 7, right: 8) - return btn - }() - - private let periodButton: UIButton = { - let btn = UIButton(type: .system) - btn.setTitle("기간 선택 ▾", for: .normal) - btn.setTitleColor(.darkGray, for: .normal) - btn.titleLabel?.font = UIFont.systemFont(ofSize: 14) - btn.layer.cornerRadius = 8 - btn.layer.borderWidth = 1 - btn.layer.borderColor = UIColor.lightGray.cgColor - btn.contentHorizontalAlignment = .left - btn.contentEdgeInsets = UIEdgeInsets(top: 7, left: 8, bottom: 7, right: 8) - return btn - }() - - private let timeButton: UIButton = { - let btn = UIButton(type: .system) - btn.setTitle("시간 선택 ▾", for: .normal) - btn.setTitleColor(.darkGray, for: .normal) - btn.titleLabel?.font = UIFont.systemFont(ofSize: 14) - btn.layer.cornerRadius = 8 - btn.layer.borderWidth = 1 - btn.layer.borderColor = UIColor.lightGray.cgColor - btn.contentHorizontalAlignment = .left - btn.contentEdgeInsets = UIEdgeInsets(top: 7, left: 8, bottom: 7, right: 8) - return btn - }() - private func extractDateRange(from state: Reactor.State) -> (Date, Date)? { - guard let start = state.selectedStartDate, - let end = state.selectedEndDate else { - return nil - } - return (start, end) - } + private var mainView: PopUpRegisterView - private func extractTimeRange(from state: Reactor.State) -> (Date, Date)? { - guard let start = state.selectedStartTime, - let end = state.selectedEndTime else { - return nil - } - return (start, end) - } - - private func areDateRangesEqual(_ prev: (Date, Date), _ current: (Date, Date)) -> Bool { - return prev.0 == current.0 && prev.1 == current.1 - } // MARK: - Initializer init(nickname: String, adminUseCase: AdminUseCase, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { self.nickname = nickname self.adminUseCase = adminUseCase + self.mainView = PopUpRegisterView() super.init() @@ -183,10 +34,10 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { ) self.reactor = reactor - self.accountIdLabel.text = nickname + "님" + self.mainView.accountIdLabel.text = nickname + "님" if editingStore != nil { - pageTitleLabel.text = "팝업스토어 수정" + self.mainView.pageTitleLabel.text = "팝업스토어 수정" // 편집 모드일 경우 스토어 상세 정보 로드 if let storeId = editingStore?.id { @@ -196,600 +47,127 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + fatalError("\(#file), \(#function) Error") } +} - // MARK: - Lifecycle +// MARK: - Life Cycle +extension PopUpStoreRegisterViewController { override func viewDidLoad() { super.viewDidLoad() - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - tapGesture.cancelsTouchesInView = false - view.addGestureRecognizer(tapGesture) - - view.backgroundColor = UIColor(white: 0.95, alpha: 1) + self.addViews() + self.setupContstraints() + self.configureUI() + self.setupHandlers() - setupNavigation() - setupLayout() - setupRows() - setupImageCollectionUI() - setupKeyboardHandling() + if let reactor = self.reactor as? PopUpStoreRegisterReactor { + self.bind(reactor: reactor) + } } +} - // MARK: - ReactorKit Binding - func bind(reactor: Reactor) { - - // 텍스트 필드 바인딩 - nameField?.rx.text.orEmpty - .distinctUntilChanged() - .skip(1) - .map { Reactor.Action.updateName($0) } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - addressField?.rx.text.orEmpty - .distinctUntilChanged() - .skip(1) - .map { Reactor.Action.updateAddress($0) } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - latField?.rx.text.orEmpty - .distinctUntilChanged() - .skip(1) - .map { Reactor.Action.updateLat($0) } - .bind(to: reactor.action) - .disposed(by: disposeBag) +// MARK: - Setup +private extension PopUpStoreRegisterViewController { + func addViews() { + self.view.addSubview(self.mainView) + } - lonField?.rx.text.orEmpty - .distinctUntilChanged() - .skip(1) - .map { Reactor.Action.updateLon($0) } - .bind(to: reactor.action) - .disposed(by: disposeBag) + func setupContstraints() { + self.mainView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } - descTV?.rx.text.orEmpty - .distinctUntilChanged() - .skip(1) - .map { Reactor.Action.updateDescription($0) } - .bind(to: reactor.action) - .disposed(by: disposeBag) + func configureUI() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.handleTap)) + tapGesture.cancelsTouchesInView = false + self.view.addGestureRecognizer(tapGesture) + } - // 주소 변경 시 지오코딩 요청 - addressField?.rx.text.orEmpty - .distinctUntilChanged() - .debounce(.milliseconds(500), scheduler: MainScheduler.instance) - .filter { !$0.isEmpty } - .map { Reactor.Action.geocodeAddress($0) } - .bind(to: reactor.action) - .disposed(by: disposeBag) + func setupHandlers() { + // 뒤로가기 버튼 + self.mainView.backButton.addTarget(self, action: #selector(self.onBack), for: .touchUpInside) - // 이미지 버튼 바인딩 - addImageButton.rx.tap + // 이미지 관련 버튼 + self.mainView.addImageButton.rx.tap .bind { [weak self] in self?.showImagePicker() } - .disposed(by: disposeBag) - - removeAllButton.rx.tap - .map { Reactor.Action.removeAllImages } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - // 다양한 버튼 바인딩 - categoryButton.rx.tap + .disposed(by: self.disposeBag) + self.mainView.removeAllButton.rx.tap + .map { PopUpStoreRegisterReactor.Action.removeAllImages } + .subscribe(onNext: { [weak self] action in + (self?.reactor as? PopUpStoreRegisterReactor)?.action.onNext(action) + }) + .disposed(by: self.disposeBag) + + self.mainView.categoryButton.rx.tap .bind { [weak self] in self?.showCategoryPicker() } - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - periodButton.rx.tap + self.mainView.periodButton.rx.tap .bind { [weak self] in self?.showDateRangePicker() } - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - timeButton.rx.tap + self.mainView.timeButton.rx.tap .bind { [weak self] in self?.showTimeRangePicker() } - .disposed(by: disposeBag) - - // 저장 버튼 - saveButton.rx.tap - .map { Reactor.Action.save } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - // Outputs (Reactor -> View) - - // 저장 버튼 활성화 상태 - reactor.state.map { $0.isSaveEnabled } - .distinctUntilChanged() - .bind(onNext: { [weak self] isEnabled in - self?.saveButton.isEnabled = isEnabled - self?.saveButton.backgroundColor = isEnabled ? .systemBlue : .lightGray - }) - .disposed(by: disposeBag) - - // 로딩 상태 - reactor.state.map { $0.isLoading } - .distinctUntilChanged() - .bind(onNext: { [weak self] isLoading in - if isLoading { - // 로딩 인디케이터 표시 - self?.showLoadingIndicator() - } else { - // 로딩 인디케이터 숨김 - self?.hideLoadingIndicator() - } - }) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - // 에러 메시지 - reactor.state.map { $0.errorMessage } - .distinctUntilChanged() - .compactMap { $0 } - .bind(onNext: { [weak self] message in - self?.showErrorAlert(message: message) - reactor.action.onNext(.clearError) - }) - .disposed(by: disposeBag) - - // 성공 상태 - reactor.state.map { $0.isSuccess } - .distinctUntilChanged() - .filter { $0 } - .bind(onNext: { [weak self] _ in - self?.showSuccessAlert(isUpdate: reactor.currentState.isEditMode) - reactor.action.onNext(.dismissSuccess) - }) - .disposed(by: disposeBag) - - // 이미지 목록 업데이트 - reactor.state.map { $0.images } - .distinctUntilChanged() - .bind(onNext: { [weak self] _ in - self?.imagesCollectionView.reloadData() - }) - .disposed(by: disposeBag) - - // 필드 값 업데이트 - reactor.state.map { $0.name } - .distinctUntilChanged() - .bind(onNext: { [weak self] name in - if self?.nameField?.text != name { - self?.nameField?.text = name - } - }) - .disposed(by: disposeBag) - - reactor.state.map { $0.address } - .distinctUntilChanged() - .bind(onNext: { [weak self] address in - if self?.addressField?.text != address { - self?.addressField?.text = address - } - }) - .disposed(by: disposeBag) - - reactor.state.map { $0.lat } - .distinctUntilChanged() - .bind(onNext: { [weak self] lat in - if self?.latField?.text != lat { - self?.latField?.text = lat - } - }) - .disposed(by: disposeBag) - - reactor.state.map { $0.lon } - .distinctUntilChanged() - .bind(onNext: { [weak self] lon in - if self?.lonField?.text != lon { - self?.lonField?.text = lon - } - }) - .disposed(by: disposeBag) - - reactor.state.map { $0.description } - .distinctUntilChanged() - .bind(onNext: { [weak self] description in - if self?.descTV?.text != description { - self?.descTV?.text = description - } - }) - .disposed(by: disposeBag) - - reactor.state.map { $0.category } - .distinctUntilChanged() - .filter { !$0.isEmpty } - .bind(onNext: { [weak self] category in - self?.updateCategoryButtonTitle(with: category) - }) - .disposed(by: disposeBag) - - // 날짜 범위 업데이트 - reactor.state - .compactMap { [weak self] state in - self?.extractDateRange(from: state) - } - .distinctUntilChanged(areDateRangesEqual) - .bind(onNext: { [weak self] dateRange in - self?.updatePeriodButtonTitle(start: dateRange.0, end: dateRange.1) - }) - .disposed(by: disposeBag) - - // 시간 범위 업데이트 - reactor.state - .compactMap { [weak self] state in - self?.extractTimeRange(from: state) - } - .distinctUntilChanged(areDateRangesEqual) - .bind(onNext: { [weak self] timeRange in - self?.updateTimeButtonTitle(start: timeRange.0, end: timeRange.1) - }) - .disposed(by: disposeBag) - } - // MARK: - UI Setup - private func setupNavigation() { - backButton.addTarget(self, action: #selector(onBack), for: .touchUpInside) - } - - private func setupLayout() { - // 상단 컨테이너 - view.addSubview(navContainer) - navContainer.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide) - make.left.right.equalToSuperview() - make.height.equalTo(44) - } - - navContainer.addSubview(logoImageView) - logoImageView.snp.makeConstraints { make in - make.left.equalToSuperview().offset(8) - make.centerY.equalToSuperview() - make.width.equalTo(22) - make.height.equalTo(35) - } - - navContainer.addSubview(accountIdLabel) - accountIdLabel.snp.makeConstraints { make in - make.centerY.equalToSuperview() - make.left.equalTo(logoImageView.snp.right).offset(8) - } - - navContainer.addSubview(menuButton) - menuButton.snp.makeConstraints { make in - make.centerY.equalToSuperview() - make.right.equalToSuperview().inset(16) - make.width.height.equalTo(32) - } - - // 타이틀 컨테이너 - view.addSubview(titleContainer) - titleContainer.snp.makeConstraints { make in - make.top.equalTo(navContainer.snp.bottom) - make.left.right.equalToSuperview() - make.height.equalTo(44) - } - - titleContainer.addSubview(backButton) - backButton.snp.makeConstraints { make in - make.centerY.equalToSuperview() - make.left.equalToSuperview().offset(8) - make.width.height.equalTo(32) + // 이미지 컬렉션뷰 핸들러 + self.mainView.imagesCollectionView.onMainImageToggled = { [weak self] index in + guard let reactor = self?.reactor as? PopUpStoreRegisterReactor else { return } + reactor.action.onNext(.toggleMainImage(index)) } - titleContainer.addSubview(pageTitleLabel) - pageTitleLabel.snp.makeConstraints { make in - make.centerY.equalToSuperview() - make.left.equalTo(backButton.snp.right).offset(4) - } - - // 스크롤뷰 - view.addSubview(scrollView) - scrollView.snp.makeConstraints { make in - make.top.equalTo(titleContainer.snp.bottom) - make.left.right.equalToSuperview() - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-74) - } - - scrollView.addSubview(contentView) - contentView.snp.makeConstraints { make in - make.edges.equalToSuperview() - make.width.equalTo(scrollView.snp.width) + self.mainView.imagesCollectionView.onImageDeleted = { [weak self] index in + guard let reactor = self?.reactor as? PopUpStoreRegisterReactor else { return } + reactor.action.onNext(.removeImage(index)) } // 저장 버튼 - view.addSubview(saveButton) - saveButton.snp.makeConstraints { make in - make.left.right.equalToSuperview().inset(16) - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) - make.height.equalTo(44) - } - } - - private func setupImageCollectionUI() { - // 버튼 스택 - let buttonStack = UIStackView(arrangedSubviews: [addImageButton, removeAllButton]) - buttonStack.axis = .horizontal - buttonStack.distribution = .fillEqually - buttonStack.spacing = 16 - - contentView.addSubview(buttonStack) - buttonStack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.left.right.equalToSuperview().inset(16) - make.height.equalTo(40) - } - - // 이미지 컬렉션 뷰 - contentView.addSubview(imagesCollectionView) - imagesCollectionView.snp.makeConstraints { make in - make.top.equalTo(buttonStack.snp.bottom).offset(8) - make.left.right.equalToSuperview().inset(16) - make.height.equalTo(130) - } - - // 폼 배경 - contentView.addSubview(formBackgroundView) - formBackgroundView.snp.makeConstraints { make in - make.top.equalTo(imagesCollectionView.snp.bottom).offset(16) - make.left.right.equalToSuperview().inset(16) - make.bottom.equalToSuperview().offset(-16) - } - - // 수직 스택 - formBackgroundView.addSubview(verticalStack) - verticalStack.axis = .vertical - verticalStack.spacing = 0 - verticalStack.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - } + self.mainView.saveButton.rx.tap + .map { PopUpStoreRegisterReactor.Action.save } + .subscribe(onNext: { [weak self] action in + (self?.reactor as? PopUpStoreRegisterReactor)?.action.onNext(action) + }) + .disposed(by: self.disposeBag) - private func setupRows() { - // 이름 필드 추가 - addRowTextField(leftTitle: "이름", placeholder: "팝업스토어 이름을 입력해 주세요.") - - // 카테고리 버튼 추가 - addRowCustom(leftTitle: "카테고리", rightView: categoryButton) - - // 위치 필드 추가 (주소, 위도, 경도) - let addressField = makeRoundedTextField("팝업스토어 주소를 입력해 주세요.") - self.addressField = addressField - - let latLabel = makePlainLabel("위도") - let latField = makeRoundedTextField("") - latField.textAlignment = .center - self.latField = latField - - let lonLabel = makePlainLabel("경도") - let lonField = makeRoundedTextField("") - lonField.textAlignment = .center - self.lonField = lonField - - let latStack = UIStackView(arrangedSubviews: [latLabel, latField]) - latStack.axis = .horizontal - latStack.spacing = 8 - latStack.distribution = .fillProportionally - - let lonStack = UIStackView(arrangedSubviews: [lonLabel, lonField]) - lonStack.axis = .horizontal - lonStack.spacing = 8 - lonStack.distribution = .fillProportionally - - let latLonRow = UIStackView(arrangedSubviews: [latStack, lonStack]) - latLonRow.axis = .horizontal - latLonRow.spacing = 16 - latLonRow.distribution = .fillEqually - - let locationVStack = UIStackView(arrangedSubviews: [addressField, latLonRow]) - locationVStack.axis = .vertical - locationVStack.spacing = 8 - locationVStack.distribution = .fillEqually - - addRowCustom(leftTitle: "위치", rightView: locationVStack, rowHeight: nil, totalHeight: 80) - - // 마커 필드 추가 - let markerLabel = makePlainLabel("마커명") - let markerField = makeRoundedTextField("") - let markerStackH = UIStackView(arrangedSubviews: [markerLabel, markerField]) - markerStackH.axis = .horizontal - markerStackH.spacing = 8 - markerStackH.distribution = .fillProportionally - - let snippetLabel = makePlainLabel("스니펫") - let snippetField = makeRoundedTextField("") - let snippetStackH = UIStackView(arrangedSubviews: [snippetLabel, snippetField]) - snippetStackH.axis = .horizontal - snippetStackH.spacing = 8 - snippetStackH.distribution = .fillProportionally - - let markerVStack = UIStackView(arrangedSubviews: [markerStackH, snippetStackH]) - markerVStack.axis = .vertical - markerVStack.spacing = 8 - markerVStack.distribution = .fillEqually - - addRowCustom(leftTitle: "마커", rightView: markerVStack, rowHeight: nil, totalHeight: 80) - - // 기간 및 시간 - addRowCustom(leftTitle: "기간", rightView: periodButton) - addRowCustom(leftTitle: "시간", rightView: timeButton) - - // 작성자 및 작성시간 - let writerLbl = makeSimpleLabel(nickname) - addRowCustom(leftTitle: "작성자", rightView: writerLbl) - - let timeLbl = setupCreationTimeLabel() - addRowCustom(leftTitle: "작성시간", rightView: timeLbl) - - // 상태값 - let statusLbl = makeSimpleLabel("진행") - addRowCustom(leftTitle: "상태값", rightView: statusLbl) - - // 설명 - let descTV = makeRoundedTextView() - self.descTV = descTV - addRowCustom(leftTitle: "설명", rightView: descTV, rowHeight: nil, totalHeight: 120) + self.setupKeyboardHandling() } - private func setupKeyboardHandling() { + func setupKeyboardHandling() { NotificationCenter.default.addObserver( self, - selector: #selector(keyboardWillShow), + selector: #selector(self.keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil ) NotificationCenter.default.addObserver( self, - selector: #selector(keyboardWillHide), + selector: #selector(self.keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil ) - scrollView.keyboardDismissMode = .interactive - } - - // MARK: - Helper Methods - private func addRowTextField(leftTitle: String, placeholder: String) { - let tf = makeRoundedTextField(placeholder) - if leftTitle == "이름" { - nameField = tf - } else if leftTitle == "주소" { - addressField = tf - } - addRowCustom(leftTitle: leftTitle, rightView: tf) - } - - private func addRowCustom(leftTitle: String, - rightView: UIView, - rowHeight: CGFloat? = 36, - totalHeight: CGFloat? = nil) { - let row = UIView() - row.backgroundColor = .white - - let leftBG = UIView() - leftBG.backgroundColor = UIColor(white: 0.94, alpha: 1) - row.addSubview(leftBG) - leftBG.snp.makeConstraints { make in - make.top.bottom.left.equalToSuperview() - make.width.equalTo(80) - } - - let leftLabel = UILabel() - leftLabel.text = leftTitle - leftLabel.font = UIFont.systemFont(ofSize: 15, weight: .bold) - leftLabel.textColor = .black - leftLabel.textAlignment = .center - leftBG.addSubview(leftLabel) - leftLabel.snp.makeConstraints { make in - make.centerY.equalToSuperview() - make.left.right.equalToSuperview().inset(8) - } - - let rightBG = UIView() - rightBG.backgroundColor = .white - row.addSubview(rightBG) - rightBG.snp.makeConstraints { make in - make.top.bottom.right.equalToSuperview() - make.left.equalTo(leftBG.snp.right) - } - - rightBG.addSubview(rightView) - rightView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(7) - make.bottom.equalToSuperview().offset(-7) - make.left.equalToSuperview().offset(8) - make.right.equalToSuperview().offset(-8) - if let fixH = rowHeight { - make.height.equalTo(fixH).priority(.medium) - } - } - - if let totalH = totalHeight { - row.snp.makeConstraints { make in - make.height.equalTo(totalH).priority(.high) - } - } else { - row.snp.makeConstraints { make in - make.height.greaterThanOrEqualTo(41) - } - } - - let separator = UIView() - separator.backgroundColor = UIColor.lightGray.withAlphaComponent(0.3) - row.addSubview(separator) - separator.snp.makeConstraints { make in - make.left.right.bottom.equalToSuperview() - make.height.equalTo(1) - } - - verticalStack.addArrangedSubview(row) - } - - private func getCurrentFormattedTime() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd HH:mm" - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") - return formatter.string(from: Date()) - } - - private func setupCreationTimeLabel() -> UILabel { - let currentTime = getCurrentFormattedTime() - return makeSimpleLabel(currentTime) - } - - private func makeRoundedTextField(_ placeholder: String) -> UITextField { - let tf = UITextField() - tf.placeholder = placeholder - tf.font = UIFont.systemFont(ofSize: 14) - tf.textColor = .darkGray - tf.borderStyle = .none - tf.layer.cornerRadius = 8 - tf.layer.borderWidth = 1 - tf.layer.borderColor = UIColor.lightGray.cgColor - tf.setLeftPaddingPoints(8) - return tf - } - - private func makeRoundedTextView() -> UITextView { - let tv = UITextView() - tv.font = UIFont.systemFont(ofSize: 14) - tv.textColor = .darkGray - tv.layer.cornerRadius = 8 - tv.layer.borderWidth = 1 - tv.layer.borderColor = UIColor.lightGray.cgColor - tv.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) - tv.isScrollEnabled = true - return tv - } - - private func makePlainLabel(_ text: String) -> UILabel { - let lbl = UILabel() - lbl.text = text - lbl.font = UIFont.systemFont(ofSize: 14) - lbl.textColor = .darkGray - lbl.textAlignment = .right - lbl.setContentHuggingPriority(.required, for: .horizontal) - return lbl - } - - private func makeSimpleLabel(_ text: String) -> UILabel { - let lbl = UILabel() - lbl.text = text - lbl.font = UIFont.systemFont(ofSize: 14) - lbl.textColor = .darkGray - return lbl + self.mainView.scrollView.keyboardDismissMode = .interactive } +} - // MARK: - UI Interaction Methods +// MARK: - UI Interaction Methods +extension PopUpStoreRegisterViewController { @objc private func handleTap() { - view.endEditing(true) + self.view.endEditing(true) } @objc private func onBack() { - navigationController?.popViewController(animated: true) + self.navigationController?.popViewController(animated: true) } @objc private func keyboardWillShow(_ notification: Notification) { @@ -806,28 +184,28 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { right: 0 ) - scrollView.contentInset = contentInset - scrollView.scrollIndicatorInsets = contentInset + self.mainView.scrollView.contentInset = contentInset + self.mainView.scrollView.scrollIndicatorInsets = contentInset // 현재 활성화된 필드가 키보드에 가려지는지 확인 - if let activeField = view.findFirstResponder() { - let activeRect = activeField.convert(activeField.bounds, to: scrollView) + if let activeField = self.view.findFirstResponder() { + let activeRect = activeField.convert(activeField.bounds, to: self.mainView.scrollView) let bottomOffset = activeRect.maxY + 20 // 여유 공간 - if bottomOffset > (scrollView.frame.height - keyboardHeight) { + if bottomOffset > (self.mainView.scrollView.frame.height - keyboardHeight) { let scrollPoint = CGPoint( x: 0, - y: bottomOffset - (scrollView.frame.height - keyboardHeight) + y: bottomOffset - (self.mainView.scrollView.frame.height - keyboardHeight) ) - scrollView.setContentOffset(scrollPoint, animated: true) + self.mainView.scrollView.setContentOffset(scrollPoint, animated: true) } } } @objc private func keyboardWillHide(_ notification: Notification) { UIView.animate(withDuration: 0.3) { - self.scrollView.contentInset = .zero - self.scrollView.scrollIndicatorInsets = .zero + self.mainView.scrollView.contentInset = .zero + self.mainView.scrollView.scrollIndicatorInsets = .zero } } @@ -840,7 +218,7 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { picker.delegate = self self.pickerViewController = picker - present(picker, animated: true, completion: nil) + self.present(picker, animated: true, completion: nil) } private func showCategoryPicker() { @@ -850,7 +228,8 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { for category in categories { let action = UIAlertAction(title: category, style: .default) { [weak self] _ in - self?.reactor?.action.onNext(.selectCategory(category)) + guard let reactor = self?.reactor as? PopUpStoreRegisterReactor else { return } + reactor.action.onNext(.selectCategory(category)) } alertController.addAction(action) } @@ -859,45 +238,47 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { alertController.addAction(cancelAction) if let popoverController = alertController.popoverPresentationController { - popoverController.sourceView = categoryButton - popoverController.sourceRect = categoryButton.bounds + popoverController.sourceView = self.mainView.categoryButton + popoverController.sourceRect = self.mainView.categoryButton.bounds } - present(alertController, animated: true, completion: nil) + self.present(alertController, animated: true, completion: nil) } private func showDateRangePicker() { DateTimePickerManager.shared.showDateRange(on: self) { [weak self] start, end in - self?.reactor?.action.onNext(.selectDateRange(start: start, end: end)) + guard let reactor = self?.reactor as? PopUpStoreRegisterReactor else { return } + reactor.action.onNext(.selectDateRange(start: start, end: end)) } } private func showTimeRangePicker() { DateTimePickerManager.shared.showTimeRange(on: self) { [weak self] start, end in - self?.reactor?.action.onNext(.selectTimeRange(start: start, end: end)) + guard let reactor = self?.reactor as? PopUpStoreRegisterReactor else { return } + reactor.action.onNext(.selectTimeRange(start: start, end: end)) } } private func updateCategoryButtonTitle(with category: String) { - categoryButton.setTitle("\(category) ▾", for: .normal) + self.mainView.categoryButton.setTitle("\(category) ▾", for: .normal) } private func updatePeriodButtonTitle(start: Date, end: Date) { - let df = DateFormatter() - df.dateFormat = "yyyy.MM.dd" - let sStr = df.string(from: start) - let eStr = df.string(from: end) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy.MM.dd" + let startString = dateFormatter.string(from: start) + let endString = dateFormatter.string(from: end) - periodButton.setTitle("\(sStr) ~ \(eStr)", for: .normal) + self.mainView.periodButton.setTitle("\(startString) ~ \(endString)", for: .normal) } private func updateTimeButtonTitle(start: Date, end: Date) { - let df = DateFormatter() - df.dateFormat = "HH:mm" - let stStr = df.string(from: start) - let etStr = df.string(from: end) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm" + let startString = dateFormatter.string(from: start) + let endString = dateFormatter.string(from: end) - timeButton.setTitle("\(stStr) ~ \(etStr)", for: .normal) + self.mainView.timeButton.setTitle("\(startString) ~ \(endString)", for: .normal) } private func showLoadingIndicator() { @@ -920,7 +301,7 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { self?.completionHandler?() // 목록 새로고침 self?.navigationController?.popViewController(animated: true) }) - present(alert, animated: true) + self.present(alert, animated: true) } private func showErrorAlert(message: String) { @@ -930,39 +311,199 @@ final class PopUpStoreRegisterViewController: BaseViewController, View { preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "확인", style: .default)) - present(alert, animated: true) + self.present(alert, animated: true) } } -// MARK: - UICollectionView DataSource & Delegate -extension PopUpStoreRegisterViewController: UICollectionViewDataSource, UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return reactor?.currentState.images.count ?? 0 - } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: ImageCell.identifier, - for: indexPath - ) as? ImageCell, - let images = reactor?.currentState.images, - indexPath.item < images.count else { - return UICollectionViewCell() - } +// MARK: - ReactorKit Binding +extension PopUpStoreRegisterViewController: View { + typealias Reactor = PopUpStoreRegisterReactor - let item = images[indexPath.item] - cell.configure(with: item) - // 대표이미지 변경 - cell.onMainCheckToggled = { [weak self] in - self?.reactor?.action.onNext(.toggleMainImage(indexPath.item)) - } + func bind(reactor: Reactor) { + // MARK: - Input (View -> Reactor) + // 텍스트 필드 바인딩 + self.mainView.nameField.rx.text.orEmpty + .distinctUntilChanged() + .map { Reactor.Action.updateName($0) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) - // 개별 삭제 - cell.onDeleteTapped = { [weak self] in - self?.reactor?.action.onNext(.removeImage(indexPath.item)) - } + self.mainView.addressField.rx.text.orEmpty + .distinctUntilChanged() + .map { Reactor.Action.updateAddress($0) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.mainView.latField.rx.text.orEmpty + .distinctUntilChanged() + .map { Reactor.Action.updateLat($0) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.mainView.lonField.rx.text.orEmpty + .distinctUntilChanged() + .map { Reactor.Action.updateLon($0) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.mainView.descriptionTextView.rx.text.orEmpty + .distinctUntilChanged() + .map { Reactor.Action.updateDescription($0) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // 주소 변경 시 지오코딩 요청 + self.mainView.addressField.rx.text.orEmpty + .distinctUntilChanged() + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .filter { !$0.isEmpty } + .map { Reactor.Action.geocodeAddress($0) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // MARK: - Output (Reactor -> View) + // 저장 버튼 활성화 상태 + reactor.state.map { $0.isSaveEnabled } + .distinctUntilChanged() + .bind(onNext: { [weak self] isEnabled in + self?.mainView.saveButton.isEnabled = isEnabled + self?.mainView.saveButton.backgroundColor = isEnabled ? .systemBlue : .lightGray + }) + .disposed(by: self.disposeBag) + + // 로딩 상태 + reactor.state.map { $0.isLoading } + .distinctUntilChanged() + .bind(onNext: { [weak self] isLoading in + if isLoading { + self?.showLoadingIndicator() + } else { + self?.hideLoadingIndicator() + } + }) + .disposed(by: self.disposeBag) + + // 에러 메시지 + reactor.state.map { $0.errorMessage } + .distinctUntilChanged() + .compactMap { $0 } + .bind(onNext: { [weak self] message in + self?.showErrorAlert(message: message) + reactor.action.onNext(.clearError) + }) + .disposed(by: self.disposeBag) + + // 성공 상태 + reactor.state.map { $0.isSuccess } + .distinctUntilChanged() + .filter { $0 } + .bind(onNext: { [weak self] _ in + self?.showSuccessAlert(isUpdate: reactor.currentState.isEditMode) + reactor.action.onNext(.dismissSuccess) + }) + .disposed(by: self.disposeBag) + + // 이미지 목록 업데이트 + reactor.state.map { $0.images } + .distinctUntilChanged() + .bind(onNext: { [weak self] images in + self?.mainView.imagesCollectionView.updateImages(images) + }) + .disposed(by: self.disposeBag) + + // 필드 값 업데이트 + reactor.state.map { $0.name } + .distinctUntilChanged() + .bind(onNext: { [weak self] name in + if self?.mainView.nameField.text != name { + self?.mainView.nameField.text = name + } + }) + .disposed(by: self.disposeBag) + + reactor.state.map { $0.address } + .distinctUntilChanged() + .bind(onNext: { [weak self] address in + if self?.mainView.addressField.text != address { + self?.mainView.addressField.text = address + } + }) + .disposed(by: self.disposeBag) + + reactor.state.map { $0.lat } + .distinctUntilChanged() + .bind(onNext: { [weak self] lat in + if self?.mainView.latField.text != lat { + self?.mainView.latField.text = lat + } + }) + .disposed(by: self.disposeBag) + + reactor.state.map { $0.lon } + .distinctUntilChanged() + .bind(onNext: { [weak self] lon in + if self?.mainView.lonField.text != lon { + self?.mainView.lonField.text = lon + } + }) + .disposed(by: self.disposeBag) + + reactor.state.map { $0.description } + .distinctUntilChanged() + .bind(onNext: { [weak self] description in + if self?.mainView.descriptionTextView.text != description { + self?.mainView.descriptionTextView.text = description + } + }) + .disposed(by: self.disposeBag) + + reactor.state.map { $0.category } + .distinctUntilChanged() + .filter { !$0.isEmpty } + .bind(onNext: { [weak self] category in + self?.updateCategoryButtonTitle(with: category) + }) + .disposed(by: self.disposeBag) + + // 날짜 범위 업데이트 + let dateRangeObservable = reactor.state + .compactMap { state -> (Date, Date)? in + guard let start = state.selectedStartDate, + let end = state.selectedEndDate else { + return nil + } + return (start, end) + } + + dateRangeObservable + .distinctUntilChanged { prev, current in + return prev.0 == current.0 && prev.1 == current.1 + } + .bind(onNext: { [weak self] dateRange in + self?.updatePeriodButtonTitle(start: dateRange.0, end: dateRange.1) + }) + .disposed(by: self.disposeBag) + + // 시간 범위 업데이트 + let timeRangeObservable = reactor.state + .compactMap { state -> (Date, Date)? in + guard let start = state.selectedStartTime, + let end = state.selectedEndTime else { + return nil + } + return (start, end) + } + + timeRangeObservable + .distinctUntilChanged { prev, current in + return prev.0 == current.0 && prev.1 == current.1 + } + .bind(onNext: { [weak self] timeRange in + self?.updateTimeButtonTitle(start: timeRange.0, end: timeRange.1) + }) + .disposed(by: self.disposeBag) - return cell } } @@ -977,7 +518,7 @@ extension PopUpStoreRegisterViewController: PHPickerViewControllerDelegate { var newImages = [ExtendedImage]() // 이미 로드된 이미지 경로 목록 (중복 방지) - let existingPaths = Set(reactor?.currentState.images.map { $0.filePath } ?? []) + let existingPaths = Set((self.reactor as? PopUpStoreRegisterReactor)?.currentState.images.map { $0.filePath } ?? []) for (index, provider) in itemProviders.enumerated() { if provider.canLoadObject(ofClass: UIImage.self) { @@ -986,7 +527,7 @@ extension PopUpStoreRegisterViewController: PHPickerViewControllerDelegate { defer { dispatchGroup.leave() } guard let image = object as? UIImage else { return } - let name = self?.reactor?.currentState.name ?? "unnamed" + let name = (self?.reactor as? PopUpStoreRegisterReactor)?.currentState.name ?? "unnamed" let uuid = UUID().uuidString let filePath = "PopUpImage/\(name)/\(uuid)/\(index).jpg" @@ -1005,37 +546,19 @@ extension PopUpStoreRegisterViewController: PHPickerViewControllerDelegate { dispatchGroup.notify(queue: .main) { [weak self] in if !newImages.isEmpty { - self?.reactor?.action.onNext(.addImages(newImages)) + guard let reactor = self?.reactor as? PopUpStoreRegisterReactor else { return } + reactor.action.onNext(.addImages(newImages)) } } } } - -// MARK: - UITextView Delegate -extension PopUpStoreRegisterViewController: UITextViewDelegate { - func textViewDidChange(_ textView: UITextView) { - if textView == descTV { - reactor?.action.onNext(.updateDescription(textView.text)) - } - } -} - -// MARK: - Helper Extensions -extension UITextField { - func setLeftPaddingPoints(_ amount: CGFloat) { - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: amount, height: frame.size.height)) - leftView = paddingView - leftViewMode = .always - } -} - extension UIView { func findFirstResponder() -> UIView? { - if isFirstResponder { + if self.isFirstResponder { return self } - for subview in subviews { + for subview in self.subviews { if let firstResponder = subview.findFirstResponder() { return firstResponder } From 2d5909d946cd754d04780ec1bddcc321bd05716f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 11 Apr 2025 14:52:33 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore/#102:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=2015=EB=B6=84=20=EB=8B=A8=EC=9C=84=EB=A1=9C=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 --- .../AdminRegister/PopUpStoreRegisterView.swift | 17 ++++++----------- .../PopUpStoreRegisterViewController.swift | 3 ++- .../Admin/Common/DateTimePickerManager.swift | 2 ++ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift index d523fc06..eeb03b80 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift @@ -88,7 +88,7 @@ final class PopUpRegisterView: UIView { $0.spacing = 0 } - // 폼 필드들 + let nameField = UITextField().then { $0.placeholder = "팝업스토어 이름을 입력해 주세요." $0.font = UIFont.systemFont(ofSize: 14) @@ -184,7 +184,6 @@ final class PopUpRegisterView: UIView { $0.isScrollEnabled = true } - // 저장 버튼 let saveButton: UIButton = { let btn = UIButton(type: .system) btn.setTitle("저장", for: .normal) @@ -326,7 +325,6 @@ private extension PopUpRegisterView { make.edges.equalToSuperview() } - // 저장 버튼 self.saveButton.snp.makeConstraints { make in make.left.right.equalToSuperview().inset(16) make.bottom.equalTo(self.safeAreaLayoutGuide).offset(-16) @@ -342,13 +340,10 @@ private extension PopUpRegisterView { } func setupFormRows() { - // 이름 필드 추가 self.addFormRow(leftTitle: "이름", rightView: self.nameField) - // 카테고리 버튼 추가 self.addFormRow(leftTitle: "카테고리", rightView: self.categoryButton) - // 위치 필드 추가 (주소, 위도, 경도) let latLabel = self.makePlainLabel("위도") let lonLabel = self.makePlainLabel("경도") @@ -402,11 +397,12 @@ private extension PopUpRegisterView { // 작성자 및 작성시간 let currentTime = self.getCurrentFormattedTime() - let writerLbl = self.makeSimpleLabel("") - let timeLbl = self.makeSimpleLabel(currentTime) + + self.writerLabel = self.makeSimpleLabel("") + let timeLabel = self.makeSimpleLabel(currentTime) - self.addFormRow(leftTitle: "작성자", rightView: writerLbl) - self.addFormRow(leftTitle: "작성시간", rightView: timeLbl) + self.addFormRow(leftTitle: "작성자", rightView: writerLabel) + self.addFormRow(leftTitle: "작성시간", rightView: timeLabel) // 상태값 let statusLbl = self.makeSimpleLabel("진행") @@ -416,7 +412,6 @@ private extension PopUpRegisterView { self.addFormRow(leftTitle: "설명", rightView: self.descriptionTextView, rowHeight: nil, totalHeight: 120) } - // 폼 행 추가 헬퍼 메서드 func addFormRow(leftTitle: String, rightView: UIView, rowHeight: CGFloat? = 36, totalHeight: CGFloat? = nil) { let row = UIView() row.backgroundColor = .white diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift index ff17f694..34744333 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift @@ -35,6 +35,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { self.reactor = reactor self.mainView.accountIdLabel.text = nickname + "님" + self.mainView.writerLabel.text = nickname if editingStore != nil { self.mainView.pageTitleLabel.text = "팝업스토어 수정" @@ -101,7 +102,7 @@ private extension PopUpStoreRegisterViewController { (self?.reactor as? PopUpStoreRegisterReactor)?.action.onNext(action) }) .disposed(by: self.disposeBag) - + self.mainView.categoryButton.rx.tap .bind { [weak self] in self?.showCategoryPicker() diff --git a/Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift b/Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift index b1de4138..9fb27041 100644 --- a/Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift +++ b/Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift @@ -77,6 +77,7 @@ final class DateTimePickerManager { let alert1 = UIAlertController(title: "시작시간 선택", message: nil, preferredStyle: .actionSheet) let dp1 = UIDatePicker() dp1.datePickerMode = .time + dp1.minuteInterval = 15 dp1.preferredDatePickerStyle = .wheels alert1.view.addSubview(dp1) @@ -107,6 +108,7 @@ final class DateTimePickerManager { let alert2 = UIAlertController(title: "종료시간 선택", message: nil, preferredStyle: .actionSheet) let dp2 = UIDatePicker() dp2.datePickerMode = .time + dp2.minuteInterval = 15 dp2.preferredDatePickerStyle = .wheels alert2.view.addSubview(dp2) From b8bc440f9e0b07b03ebfd681e127301d2381f759 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 13 Apr 2025 05:40:19 +0000 Subject: [PATCH 4/4] style/#102: Apply SwiftLint autocorrect --- .../AdminRegister/PopUpStoreRegisterReactor.swift | 12 +----------- .../Admin/AdminRegister/PopUpStoreRegisterView.swift | 3 +-- .../PopUpStoreRegisterViewController.swift | 7 +++---- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift index 124408ac..e2c6cb67 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterReactor.swift @@ -6,7 +6,6 @@ import ReactorKit import RxCocoa import RxSwift - final class PopUpStoreRegisterReactor: Reactor { // MARK: - Properties @@ -17,7 +16,6 @@ final class PopUpStoreRegisterReactor: Reactor { private var disposeBag = DisposeBag() - init(adminUseCase: AdminUseCase, presignedService: PreSignedService, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { self.adminUseCase = adminUseCase self.presignedService = presignedService @@ -145,8 +143,6 @@ final class PopUpStoreRegisterReactor: Reactor { case let .updateName(name): return .just(.setName(name)) - - case let .updateAddress(address): return .just(.setAddress(address)) @@ -208,8 +204,6 @@ final class PopUpStoreRegisterReactor: Reactor { ]) } - - // 스토어 상세 정보 로드 (수정 모드) case let .loadStoreDetail(storeId): return Observable.concat([ @@ -486,7 +480,6 @@ final class PopUpStoreRegisterReactor: Reactor { return true } - // 주소 지오코딩 private func geocodeAddress(address: String) -> Observable { Logger.log(message: "지오코딩 함수 호출: \(address)", category: .debug) @@ -652,7 +645,7 @@ final class PopUpStoreRegisterReactor: Reactor { dispatchGroup.enter() if let imageURL = self.presignedService.fullImageURL(from: imageData.imageUrl) { - URLSession.shared.dataTask(with: imageURL) { data, response, error in + URLSession.shared.dataTask(with: imageURL) { data, _, error in defer { dispatchGroup.leave() } if let error = error { @@ -732,7 +725,6 @@ final class PopUpStoreRegisterReactor: Reactor { .map { _ in updatedImages.map { $0.filePath } } } - // 신규 스토어 등록 private func createStore() -> Observable { return uploadImages() @@ -813,7 +805,6 @@ final class PopUpStoreRegisterReactor: Reactor { .map { _ in updatedImages.map { $0.filePath } } } - // 스토어 정보 업데이트 private func updateStoreInfo(_ newImagePaths: [String]?) -> Observable { guard let storeId = editingStoreId else { @@ -905,4 +896,3 @@ final class PopUpStoreRegisterReactor: Reactor { } } - diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift index eeb03b80..f071b3f5 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterView.swift @@ -88,7 +88,6 @@ final class PopUpRegisterView: UIView { $0.spacing = 0 } - let nameField = UITextField().then { $0.placeholder = "팝업스토어 이름을 입력해 주세요." $0.font = UIFont.systemFont(ofSize: 14) @@ -397,7 +396,7 @@ private extension PopUpRegisterView { // 작성자 및 작성시간 let currentTime = self.getCurrentFormattedTime() - + self.writerLabel = self.makeSimpleLabel("") let timeLabel = self.makeSimpleLabel(currentTime) diff --git a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift index 34744333..15e6855b 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminRegister/PopUpStoreRegisterViewController.swift @@ -1,11 +1,11 @@ -import UIKit import CoreLocation import PhotosUI +import UIKit -import SnapKit +import ReactorKit import RxCocoa import RxSwift -import ReactorKit +import SnapKit final class PopUpStoreRegisterViewController: BaseViewController { @@ -320,7 +320,6 @@ extension PopUpStoreRegisterViewController { extension PopUpStoreRegisterViewController: View { typealias Reactor = PopUpStoreRegisterReactor - func bind(reactor: Reactor) { // MARK: - Input (View -> Reactor) // 텍스트 필드 바인딩