diff --git a/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/DiaryRepository.swift b/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/DiaryRepository.swift index f051ab19..707f0de7 100644 --- a/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/DiaryRepository.swift +++ b/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/DiaryRepository.swift @@ -10,9 +10,9 @@ import Foundation import CoreNetwork protocol DiaryRepository { - func createDiary(scheduleId: Int, content: String, images: [Data?]) async -> CreateDiaryResponseDTO? - func getMonthDiary(request: GetDiaryRequestDTO) async -> GetDiaryResponseDTO? - func getOneDiary(scheduleId: Int) async -> GetOneDiaryResponseDTO? - func changeDiary(scheduleId: Int, content: String, images: [Data?], deleteImageIds: [Int]) async -> Bool - func deleteDiary(scheduleId: Int) async -> Bool +// func createDiary(scheduleId: Int, content: String, images: [Data?]) async -> CreateDiaryResponseDTO? +// func getMonthDiary(request: GetDiaryRequestDTO) async -> GetDiaryResponseDTO? +// func getOneDiary(scheduleId: Int) async -> GetOneDiaryResponseDTO? +// func changeDiary(scheduleId: Int, content: String, images: [Data?], deleteImageIds: [Int]) async -> Bool +// func deleteDiary(scheduleId: Int) async -> Bool } diff --git a/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/MoimDiaryRepository.swift b/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/MoimDiaryRepository.swift index cf584307..6e7f7527 100644 --- a/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/MoimDiaryRepository.swift +++ b/Namo_SwiftUI/Projects/App/Sources/old/Data/Repository/Diary/MoimDiaryRepository.swift @@ -18,6 +18,6 @@ protocol MoimDiaryRepository { func deleteMoimDiary(moimScheduleId: Int) async -> Bool func getMonthMoimDiary(req: GetMonthMoimDiaryReqDTO) async -> GetMonthMoimDiaryResDTO? func getOneMoimDiary(moimScheduleId: Int) async -> GetOneMoimDiaryResDTO? - func getOneMoimDiaryDetail(moimScheduleId: Int) async -> Diary? + func getOneMoimDiaryDetail(moimScheduleId: Int) async -> Diary_Old? func deleteMoimDiaryOnPersonal(scheduleId: Int) async -> Bool } diff --git a/Namo_SwiftUI/Projects/Core/Network/Sources/API/EndPoint/Diary/DiaryEndPoint.swift b/Namo_SwiftUI/Projects/Core/Network/Sources/API/EndPoint/Diary/DiaryEndPoint.swift index 3f797556..19524d9d 100644 --- a/Namo_SwiftUI/Projects/Core/Network/Sources/API/EndPoint/Diary/DiaryEndPoint.swift +++ b/Namo_SwiftUI/Projects/Core/Network/Sources/API/EndPoint/Diary/DiaryEndPoint.swift @@ -13,39 +13,66 @@ import SharedUtil public enum DiaryEndPoint { case getCalendarByMonth(ym: YearMonth) case getDiaryByDate(ymd: YearMonthDay) + case getDiaryBySchedule(id: Int) + case patchDiary(id: Int, reqDto: DiaryPatchRequestDTO) + case postDiary(reqDto: DiaryPostRequestDTO) + case deleteDiary(id: Int) } extension DiaryEndPoint: EndPoint { - public var baseURL: String { - return "\(SecretConstants.baseURL)/diaries" - } - - public var path: String { - switch self { - case .getCalendarByMonth(let ym): - return "/calendar/\(ym.year)-\(String(format: "%02d", ym.month))" - case .getDiaryByDate(let ymd): - return "/date/\(ymd.year)-\(String(format: "%02d", ymd.month))-\(String(format: "%02d", ymd.day))" - } - } - - public var method: Alamofire.HTTPMethod { - switch self { - case .getCalendarByMonth: - return .get - case .getDiaryByDate: - return .get - } - } - - public var task: APITask { - switch self { - case .getCalendarByMonth: - return .requestPlain - case .getDiaryByDate: - return .requestPlain - } - } - - + public var baseURL: String { + return "\(SecretConstants.baseURL)/diaries" + } + + public var path: String { + switch self { + case .getCalendarByMonth(let ym): + return "/calendar/\(ym.year)-\(String(format: "%02d", ym.month))" + case .getDiaryByDate(let ymd): + return "/date/\(ymd.year)-\(String(format: "%02d", ymd.month))-\(String(format: "%02d", ymd.day))" + case .getDiaryBySchedule(let id): + return "/\(id)" + case .patchDiary(let id, _): + return "/\(id)" + case .postDiary: + return "" + case .deleteDiary(let id): + return "/\(id)" + } + } + + public var method: Alamofire.HTTPMethod { + switch self { + case .getCalendarByMonth: + return .get + case .getDiaryByDate: + return .get + case .getDiaryBySchedule: + return .get + case .patchDiary: + return .patch + case .postDiary: + return .post + case .deleteDiary: + return .delete + } + } + + public var task: APITask { + switch self { + case .getCalendarByMonth: + return .requestPlain + case .getDiaryByDate: + return .requestPlain + case .getDiaryBySchedule: + return .requestPlain + case .patchDiary(_, let reqDto): + return .requestJSONEncodable(parameters: reqDto) + case .postDiary(let reqDto): + return .requestJSONEncodable(parameters: reqDto) + case .deleteDiary: + return .requestPlain + } + } + } diff --git a/Namo_SwiftUI/Projects/Core/Network/Sources/DTO/DiaryDTO.swift b/Namo_SwiftUI/Projects/Core/Network/Sources/DTO/DiaryDTO.swift index aa7c2a01..87e0e568 100644 --- a/Namo_SwiftUI/Projects/Core/Network/Sources/DTO/DiaryDTO.swift +++ b/Namo_SwiftUI/Projects/Core/Network/Sources/DTO/DiaryDTO.swift @@ -3,11 +3,63 @@ // Namo_SwiftUI // // Created by 서은수 on 3/16/24. +// Updated by 박민서 on 11/13/24. // import Foundation -public struct Diary: Decodable { +public struct DiaryResponseDTO: Decodable { + public let diaryId: Int + public let content: String + public let enjoyRating: Int + public let diaryImages: [DiaryImageResponseDTO] +} + +public struct DiaryImageResponseDTO: Decodable { + public let orderNumber: Int + public let diaryImageId: Int + public let imageUrl: String +} + +public struct DiaryPatchRequestDTO: Encodable { + public let content: String + public let enjoyRating: Int + public let diaryImages: [DiaryImageRequestDTO] + public let deleteImages: [Int] + + public init(content: String, enjoyRating: Int, diaryImages: [DiaryImageRequestDTO], deleteImages: [Int]) { + self.content = content + self.enjoyRating = enjoyRating + self.diaryImages = diaryImages + self.deleteImages = deleteImages + } +} + +public struct DiaryPostRequestDTO: Encodable { + public let scheduleId: Int + public let content: String + public let enjoyRating: Int + public let diaryImages: [DiaryImageRequestDTO] + + public init(scheduleId: Int, content: String, enjoyRating: Int, diaryImages: [DiaryImageRequestDTO]) { + self.scheduleId = scheduleId + self.content = content + self.enjoyRating = enjoyRating + self.diaryImages = diaryImages + } +} + +public struct DiaryImageRequestDTO: Encodable { + public let orderNumber: Int + public let imageUrl: String + + public init(orderNumber: Int, imageUrl: String) { + self.orderNumber = orderNumber + self.imageUrl = imageUrl + } +} + +public struct Diary_Old: Decodable { public var scheduleId: Int public var name: String public var startDate: Int @@ -45,7 +97,7 @@ public struct GetDiaryRequestDTO: Encodable { } public struct GetDiaryResponseDTO: Decodable { - public init(content: [Diary], currentPage: Int, size: Int, first: Bool, last: Bool) { + public init(content: [Diary_Old], currentPage: Int, size: Int, first: Bool, last: Bool) { self.content = content self.currentPage = currentPage self.size = size @@ -53,7 +105,7 @@ public struct GetDiaryResponseDTO: Decodable { self.last = last } - public var content: [Diary] + public var content: [Diary_Old] public var currentPage: Int public var size: Int public var first: Bool diff --git a/Namo_SwiftUI/Projects/Domain/Diary/Sources/Mapper/DiaryMapper.swift b/Namo_SwiftUI/Projects/Domain/Diary/Sources/Mapper/DiaryMapper.swift index 59f10da8..d0492b83 100644 --- a/Namo_SwiftUI/Projects/Domain/Diary/Sources/Mapper/DiaryMapper.swift +++ b/Namo_SwiftUI/Projects/Domain/Diary/Sources/Mapper/DiaryMapper.swift @@ -55,3 +55,59 @@ extension DiaryScheduleParticipantDTO { ) } } + +extension DiaryResponseDTO { + func toEntity() -> Diary { + return Diary( + id: diaryId, + content: content, + enjoyRating: enjoyRating, + images: mapAndSortDiaryImages(from: diaryImages) + ) + } + + func mapAndSortDiaryImages(from responseDTOs: [DiaryImageResponseDTO]) -> [DiaryImage] { + return responseDTOs + .sorted(by: { $0.orderNumber < $1.orderNumber }) + .map { DiaryImage(id: $0.diaryImageId, orderNumber: $0.orderNumber, imageUrl: $0.imageUrl) } + } +} + +extension Diary { + func toPostDTO(scheduleId: Int) -> DiaryPostRequestDTO { + return DiaryPostRequestDTO( + scheduleId: scheduleId, + content: content, + enjoyRating: enjoyRating, + diaryImages: images.map { $0.toDTO() } + ) + } + + func toPatchDTO(deleteImages: [Int]) -> DiaryPatchRequestDTO { + return DiaryPatchRequestDTO( + content: self.content, + enjoyRating: self.enjoyRating, + diaryImages: self.images.map { $0.toDTO() }, + deleteImages: deleteImages + ) + } +} + +extension DiaryImageResponseDTO { + func toEntity() -> DiaryImage { + return DiaryImage( + id: diaryImageId, + orderNumber: orderNumber, + imageUrl: imageUrl + ) + } +} + +extension DiaryImage { + func toDTO() -> DiaryImageRequestDTO { + return DiaryImageRequestDTO( + orderNumber: orderNumber, + imageUrl: imageUrl + ) + } +} diff --git a/Namo_SwiftUI/Projects/Domain/Diary/Sources/Model/Diary.swift b/Namo_SwiftUI/Projects/Domain/Diary/Sources/Model/Diary.swift new file mode 100644 index 00000000..fb26c1b3 --- /dev/null +++ b/Namo_SwiftUI/Projects/Domain/Diary/Sources/Model/Diary.swift @@ -0,0 +1,41 @@ +// +// Diary.swift +// DomainDiary +// +// Created by 박민서 on 11/14/24. +// + +public struct Diary: Equatable { + public let id: Int? + public var content: String + public var enjoyRating: Int + public var images: [DiaryImage] + + public init( + id: Int? = nil, + content: String = "", + enjoyRating: Int = 0, + images: [DiaryImage] = [] + ) { + self.id = id + self.content = content + self.enjoyRating = enjoyRating + self.images = images + } +} + +public struct DiaryImage: Equatable { + public let id: Int? + public var orderNumber: Int + public let imageUrl: String + + public init( + id: Int? = nil, + orderNumber: Int, + imageUrl: String + ) { + self.id = id + self.orderNumber = orderNumber + self.imageUrl = imageUrl + } +} diff --git a/Namo_SwiftUI/Projects/Domain/Diary/Sources/UseCase/DiaryUseCase.swift b/Namo_SwiftUI/Projects/Domain/Diary/Sources/UseCase/DiaryUseCase.swift index 12121f3d..6d7eb2c2 100644 --- a/Namo_SwiftUI/Projects/Domain/Diary/Sources/UseCase/DiaryUseCase.swift +++ b/Namo_SwiftUI/Projects/Domain/Diary/Sources/UseCase/DiaryUseCase.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import SwiftUICalendar import CoreNetwork +import SharedUtil @DependencyClient public struct DiaryUseCase { @@ -66,6 +67,85 @@ public struct DiaryUseCase { return "00:00 - 23:59" } } + + /// SchduleId를 통해 해당 일정의 기록을 가져옵니다 + public func getDiaryBySchedule(id: Int) async throws -> Diary { + let response: BaseResponse = try await APIManager.shared.performRequest(endPoint: DiaryEndPoint.getDiaryBySchedule(id: id)) + + if response.code != 200 { + throw APIError.customError("기록 로드 실패: 응답 코드 \(response.code)") + } + + guard let diary = response.result?.toEntity() else { + throw APIError.parseError("result.result is nil") + } + + return diary + } + + /// 해당 일정의 기록을 수정합니다 + public func patchDiary(id: Int, reqDiary: Diary, deleteImages: [Int]) async throws -> Void { + let response: BaseResponse = try await APIManager.shared.performRequest(endPoint: DiaryEndPoint.patchDiary(id: id, reqDto: reqDiary.toPatchDTO(deleteImages: deleteImages))) + + if response.code != 200 { + throw APIError.customError("기록 수정 실패: 응답 코드 \(response.code)") + } + } + + /// 해당 ScheduleId의 일정의 기록을 추가합니다 + public func postDiary(scheduleId: Int, reqDiary: Diary) async throws -> Void { + let response: BaseResponse = try await APIManager.shared.performRequest(endPoint: DiaryEndPoint.postDiary(reqDto: reqDiary.toPostDTO(scheduleId: scheduleId))) + + if response.code != 200 { + throw APIError.customError("기록 작성 실패: 응답 코드 \(response.code)") + } + } + + /// 이미지 파일들을 S3에 업로드 후, 순서를 맞춰 DiaryImage 배열로 반환합니다. + public func postDiaryImages(scheduleId: Int, images: [UIImage]) async throws -> [DiaryImage] { + let compImgs = try images.map { image in + guard let compressedData = image.jpegData(compressionQuality: 0.6) else { + throw NSError(domain: "이미지 압축 에러", code: 1001) + } + return compressedData + } + + // 이미지 병렬 업로드 처리 + return try await withThrowingTaskGroup(of: (Int, String).self) { group in + for (index, img) in compImgs.enumerated() { + group.addTask { + let fileName = "diary_image_schedule_\(scheduleId)_index_\(index)_\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString)" + + guard let url = try await APIManager.shared.getPresignedUrl(prefix: "diary", filename: fileName).result else { + throw APIError.customError("S3 getPresignedUrl 에러") + } + + guard let uploadedUrl = try await APIManager.shared.uploadImageToS3(presignedUrl: url, imageFile: img) else { + throw APIError.customError("S3 uploadImageToS3 에러") + } + + return (index, uploadedUrl) + } + } + + var uploadedImages: [DiaryImage] = [] + for try await (index, uploadedUrl) in group { + // TODO: index 0부터인지 1부터인지 확인 + uploadedImages.append(DiaryImage(orderNumber: index, imageUrl: uploadedUrl)) + } + + return uploadedImages + } + } + + /// 해당 일정 기록을 삭제합니다. + public func deleteDiary(id: Int) async throws -> Void { + let response: BaseResponse = try await APIManager.shared.performRequest(endPoint: DiaryEndPoint.deleteDiary(id: id)) + + if response.code != 200 { + throw APIError.customError("기록 삭제 실패: 응답 코드 \(response.code)") + } + } } extension DiaryUseCase: DependencyKey { diff --git a/Namo_SwiftUI/Projects/Domain/Schedule/Sources/Model/Schedule.swift b/Namo_SwiftUI/Projects/Domain/Schedule/Sources/Model/Schedule.swift index ee1a8c5e..0b466a77 100644 --- a/Namo_SwiftUI/Projects/Domain/Schedule/Sources/Model/Schedule.swift +++ b/Namo_SwiftUI/Projects/Domain/Schedule/Sources/Model/Schedule.swift @@ -117,7 +117,7 @@ public struct ScheduleNotification: Decodable, Hashable { public extension Schedule { static let dummySchedules: [Schedule] = [ Schedule( - scheduleId: 1, + scheduleId: 321, title: "Test1", categoryInfo: ScheduleCategory( categoryId: 1, diff --git a/Namo_SwiftUI/Projects/Feature/Home/Sources/RecordEdit/HomeDiaryEditStore.swift b/Namo_SwiftUI/Projects/Feature/Home/Sources/RecordEdit/HomeDiaryEditStore.swift new file mode 100644 index 00000000..fac759ab --- /dev/null +++ b/Namo_SwiftUI/Projects/Feature/Home/Sources/RecordEdit/HomeDiaryEditStore.swift @@ -0,0 +1,438 @@ +// +// HomeDiaryEditStore.swift +// FeatureHome +// +// Created by 박민서 on 10/31/24. +// + +import ComposableArchitecture + +import DomainSchedule +import _PhotosUI_SwiftUI +import SharedDesignSystem +import SharedUtil +import DomainDiary +import Kingfisher + +@Reducer +public struct HomeDiaryEditStore { + + @Dependency(\.diaryUseCase) var diaryUseCase + public static let contentLimit: Int = 200 + public init() {} + + @ObservableState + public struct State: Equatable { + public init(schedule: Schedule, hasDiary: Bool) { + self.schedule = schedule + self.isRevise = hasDiary + } + + /// 기존 게시물 여부 - API 응답 결과 + let isRevise: Bool + /// 컨텐츠 수정 상태 + var isChanged: Bool { initialDiary != diary || initialImages != selectedImages } + + /// 스케쥴 + let schedule: Schedule + var scheduleName: String { schedule.title } + var monthString: String { schedule.startDate.toMM() } + var dayString: String { schedule.startDate.toDD() } + var dateString: String { "\(schedule.startDate.toYMDEHM()) \n - \(schedule.endDate.toYMDEHM())" } + var placeName: String { schedule.locationInfo?.locationName ?? "" } + + // 기록 + var initialDiary: Diary = Diary() + var diary: Diary = Diary() + + /// 본문 조건 적합 체크 + var isContentValid: Bool = true + // 이미지 + var selectedItems: [PhotosPickerItem] = [] + var initialImages: [Data] = [] + var selectedImages: [Data] = [] + var deletedImages: [Int] = [] + /// 저장 버튼 상태 + var saveButtonState: NamoButton.NamoButtonType { + return isChanged ? .active : .inactive + } + /// 토스트 표시 + var showToast: Bool = false + /// 토스트 컨텐츠 + var toast: Toast = .none + /// alert 컨텐츠 + var alertContent: AlertType = .none + /// alert 표시 + var showAlert: Bool = false + } + + public enum Action: BindableAction { + case binding(BindingAction) + case tapEnjoyRating(Int) + case typeContent(String) + case validateContent(String) + case selectPhoto(PhotosPickerItem) + case addImage(Result) + case deleteImage(Int) + case tapBackButton + case tapDeleteDiaryButton + case tapSaveDiaryButton + case handleAlertConfirm + case updateInitialValues + case dismiss + case onAppear + case showToast(Toast) + case showAlert(AlertType) + case loadDiary + case loadDiaryCompleted(Diary) + case loadDiaryImages(Diary) + case loadInitialImages([Data], Diary) + case postDiaryImages([UIImage]) + case addPostedDiaryImages([DiaryImage]) + case postDiary + case patchDiaryImages([UIImage]) + case addPatchedDiaryImages([DiaryImage]) + case patchDiary + case deleteDiary + } + + public var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + + case .binding: + return .none + + case .tapEnjoyRating(let rate): + state.diary.enjoyRating = rate + return .none + + case .typeContent(let content): + state.diary.content = content + return .send(.validateContent(content)) + + case .validateContent(let content): + state.isContentValid = content.count <= Self.contentLimit + return .none + + case .selectPhoto(let pickerItem): + return .run { send in + await send(.addImage(Result { + try await pickerItem.loadTransferable(type: Data.self) + })) + } + + case .addImage(.success(let data)): + guard let data else { + return .send(.showToast(.addImageFailed)) + } + guard state.selectedImages.count < 3 else { + return .send(.showToast(.custom("3개가 넘습니다"))) + } + state.selectedImages.append(data) + return .none + + case .addImage(.failure(let error)): + print("error occured: \(error.localizedDescription)") + return .send(.showToast(.addImageFailed)) + + case .deleteImage(let index): + let deletedItem = state.selectedImages.remove(at: index) + // 기존에 로드한 이미지를 삭제하는 경우 + if let initialIndex = state.initialImages.firstIndex(where: { $0 == deletedItem }) { + state.diary.images.remove(at: initialIndex) + state.deletedImages.append(initialIndex) + } + return .none + + case .tapBackButton: + return state.isChanged + ? .send(.showAlert(.backWithoutSave)) + : .none + + case .tapDeleteDiaryButton: + return .send(.showAlert(.deleteDiary)) + + case .tapSaveDiaryButton: + guard state.saveButtonState == .active else { return .none } + + let imgChanged = state.initialImages != state.selectedImages + + if state.isRevise { + if imgChanged { + let changedImages = state.selectedImages + .filter { !state.initialImages.contains($0) } + .compactMap { UIImage(data: $0) } + return .send(.patchDiaryImages(changedImages)) + } else { + return .send(.patchDiary) + } + } else { + if imgChanged { + let images: [UIImage] = state.selectedImages + .compactMap { UIImage(data: $0) } + return .send(.postDiaryImages(images)) + } else { + return .send(.postDiary) + } + } + + case .updateInitialValues: + state.initialDiary = state.diary + state.initialImages = state.selectedImages + return .none + + case .handleAlertConfirm: + switch state.alertContent { + + case .deleteDiary: + state.alertContent = .none + return .send(.deleteDiary) + case .backWithoutSave, .loadFailed: + state.alertContent = .none + return .send(.dismiss) + default: + state.alertContent = .none + return .none + } + + case .dismiss: + print("dismiss") + return .none + + case .onAppear: + return state.isRevise + ? .send(.loadDiary) + : .none + + case .showToast(let toast): + state.toast = toast + state.showToast = true + return .none + + case .showAlert(let alert): + state.alertContent = alert + state.showAlert = true + return .none + + case .loadDiary: + return .run { [id = state.schedule.scheduleId] send in + do { + let diary = try await diaryUseCase.getDiaryBySchedule(id: id) + if !diary.images.isEmpty { + await send(.loadDiaryImages(diary)) + } else { + await send(.loadDiaryCompleted(diary)) + } + } catch { + print(error.localizedDescription) + await send(.showAlert(.loadFailed)) + } + } + + case .loadDiaryImages(let diary): + return .run { send in + do { + let updatedImages = try await withThrowingTaskGroup(of: (Int,Data).self) { group in + + for diaryImage in diary.images { + group.addTask { + guard let url = URL(string: diaryImage.imageUrl) else { + throw APIError.customError("잘못된 URL") + } + + let data = try await withCheckedThrowingContinuation { continuation in + + KingfisherManager.shared.retrieveImage(with: url) { result in + switch result { + case .success(let value): + continuation.resume(returning: value.image.pngData() ?? Data()) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + return (diaryImage.orderNumber, data) + } + } + + var imagesDict: [Int: Data] = [:] + for try await (orderNumber, data) in group { + imagesDict[orderNumber] = data + } + + let sortedImages = imagesDict.keys.sorted().compactMap { imagesDict[$0] } + return sortedImages + + } + + // 최종 updatedImages 형태는 orderNumber대로 정렬된 imgData + for image in updatedImages { + await send(.addImage(.success(image))) + } + + await send(.loadInitialImages(updatedImages, diary)) + } catch { + print(error.localizedDescription) + await send(.showAlert(.loadFailed)) + } + } + + case .loadInitialImages(let imgs, let diary): + imgs.forEach { state.initialImages.append($0) } + return .send(.loadDiaryCompleted(diary)) + + case .loadDiaryCompleted(let diary): + state.diary = diary + state.initialDiary = diary + return .none + + case .postDiaryImages(let imgs): + return .run { [id = state.schedule.scheduleId] send in + do { + let diaryImgs = try await diaryUseCase.postDiaryImages(scheduleId: id, images: imgs) + await send(.addPostedDiaryImages(diaryImgs)) + } catch { + print(error.localizedDescription) + await send(.showToast(.uploadImageFailed)) + } + } + + case .addPostedDiaryImages(let diaryImgs): + state.diary.images = diaryImgs + return .send(.postDiary) + + case .postDiary: + return .run { [ + id = state.schedule.scheduleId, + diary = state.diary + ] send in + do { + try await diaryUseCase.postDiary(scheduleId: id, reqDiary: diary) + await send(.updateInitialValues) + await send(.showToast(.saveSuccess)) + } + catch { + await send(.showToast(.saveFailed)) + } + } + + case .patchDiaryImages(let imgs): + return .run { [id = state.schedule.scheduleId] send in + do { + let diaryImgs = try await diaryUseCase.postDiaryImages(scheduleId: id, images: imgs) + await send(.addPatchedDiaryImages(diaryImgs)) + } catch { + print(error.localizedDescription) + await send(.showToast(.uploadImageFailed)) + } + } + + case .addPatchedDiaryImages(let diaryImgs): + + let sortedDiaryImgs = diaryImgs.sorted { $0.orderNumber < $1.orderNumber } + state.diary.images.append(contentsOf: sortedDiaryImgs) + // 기존 이미지 순서 + 새로운 이미지 orderNum 순서 차례대로 새롭게 orderNum 배정 + state.diary.images = state.diary.images.enumerated().map { index, img in + var updatedImg = img + updatedImg.orderNumber = index + return updatedImg + } + + return .send(.patchDiary) + + case .patchDiary: + return .run { [diary = state.diary, deleted = state.deletedImages] send in + + do { + guard let id = diary.id else { + throw NSError.init(domain: "diaryId is nil", code: 1001) + } + + try await diaryUseCase.patchDiary(id: id, reqDiary: diary, deleteImages: deleted) + await send(.updateInitialValues) + await send(.showToast(.saveSuccess)) + } + catch { + await send(.showToast(.saveFailed)) + } + + } + + case .deleteDiary: + return .run { [diary = state.diary] send in + do { + guard let id = diary.id else { + throw NSError.init(domain: "diaryId is nil", code: 1001) + } + try await diaryUseCase.deleteDiary(id: id) + await send(.dismiss) + } + catch { + await send(.showToast(.deleteFailed)) + } + } + } + } + } +} + +extension HomeDiaryEditStore { + public enum Toast: Equatable { + case none + case saveSuccess + case saveFailed + case addImageFailed + case uploadImageFailed + case deleteFailed + case custom(String) + + var content: String { + switch self { + + case .none: + return "" + case .saveSuccess: + return "기록이 저장되었습니다." + case .saveFailed: + return "기록 저장에 실패했습니다.\n다시 시도해주세요." + case .addImageFailed: + return "사진 추가에 실패했습니다.\n다시 시도해주세요." + case .uploadImageFailed: + return "사진 업로드에 실패했습니다.\n다시 시도해주세요." + case .deleteFailed: + return "기록 삭제에 실패했습니다.\n다시 시도해주세요." + case .custom(let content): + return content + } + } + } +} + +extension HomeDiaryEditStore { + public enum AlertType: Equatable { + case none + case deleteDiary + case backWithoutSave + case loadFailed + case custom(NamoAlertContent) + + public var content: NamoAlertContent { + switch self { + case .none: + return NamoAlertContent() + case .deleteDiary: + return NamoAlertContent(title: "기록을 정말 삭제하시겠어요?") + case .backWithoutSave: + return NamoAlertContent(title: "편집된 내용이 저장되지 않습니다.", message: "정말 나가시겠어요?") + case .loadFailed: + return NamoAlertContent(title: "기록 불러오기에 실패했습니다.", message: "다시 시도해주세요.") + case .custom(let content): + return NamoAlertContent(title: content.title, message: content.title) + } + } + } +} + diff --git a/Namo_SwiftUI/Projects/Feature/Home/Sources/RecordEdit/HomeDiaryEditView.swift b/Namo_SwiftUI/Projects/Feature/Home/Sources/RecordEdit/HomeDiaryEditView.swift new file mode 100644 index 00000000..3a4edb93 --- /dev/null +++ b/Namo_SwiftUI/Projects/Feature/Home/Sources/RecordEdit/HomeDiaryEditView.swift @@ -0,0 +1,265 @@ +// +// HomeDiaryEditView.swift +// FeatureHome +// +// Created by 박민서 on 10/30/24. +// + +import SwiftUI +import SharedDesignSystem +import SharedUtil +import _PhotosUI_SwiftUI +import ComposableArchitecture + +public struct HomeDiaryEditView: View { + + @Perception.Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 0) { + + DatePlaceView(store: store) + .padding(.horizontal, 30) + .padding(.bottom, 24) + + EnjoyRateView(store: store) + .padding(.horizontal, 45) + .padding(.bottom, 16) + + ContentView(store: store) + .padding(.horizontal, 30) + .padding(.bottom, 20) + + DiaryImageView(store: store) + .padding(.horizontal, 30) + + Spacer() + + NamoButton( + title: store.isRevise ? "변경 내용 저장" : "기록 저장", + font: .pretendard(.bold, size: 15), + cornerRadius: 0, + verticalPadding: 30, + type: store.saveButtonState, + action: { + store.send(.tapSaveDiaryButton) + } + ) + } + .onAppear { store.send(.onAppear) } + .namoNabBar(center: { + Text(store.scheduleName) + .font(.pretendard(.bold, size: 22)) + }, left: { + Button(action: { + store.send(.tapBackButton, animation: .default) + }, label: { + Image(asset: SharedDesignSystemAsset.Assets.icArrowLeftThick) + .frame(width: 32, height: 32) + }) + }, right: { + Button(action: { + store.send(.tapDeleteDiaryButton, animation: .default) + }, label: { + Image(asset: SharedDesignSystemAsset.Assets.icTrashcan) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .disabled(!store.isRevise) + .hidden(!store.isRevise) + }) + }) + .ignoresSafeArea(.container, edges: .bottom) + .namoToastView( + isPresented: $store.showToast, + title: store.toast.content, + isTabBarScreen: false + ) + .namoAlertView( + isPresented: $store.showAlert, + title: store.alertContent.content.title, + content: store.alertContent.content.message, + confirmAction: { + store.send(.handleAlertConfirm) + } + ) + } + } +} + +// MARK: DatePlaceView +private extension HomeDiaryEditView { + func DatePlaceView(store: StoreOf) -> some View { + HStack(spacing: 25) { + DateCircleView(monthString: store.monthString, dayString: store.dayString) + + VStack(alignment: .leading, spacing: 12) { + DatePlaceItem(title: "날짜", content: store.dateString) + DatePlaceItem(title: "장소", content: store.placeName) + } + } + } + + func DateCircleView(monthString: String, dayString: String) -> some View { + VStack { + Text(monthString) + .font(.pretendard(.bold, size: 15)) + .foregroundStyle(Color.mainText) + + Text(dayString) + .font(.pretendard(.bold, size: 36)) + .foregroundStyle(Color.mainText) + } + .padding(20) + .background { + Circle() + .foregroundColor(.white) + .shadow(color: .black.opacity(0.15), radius: 8) + } + } + + func DatePlaceItem(title: String, content: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text(title) + .font(.pretendard(.bold, size: 15)) + .foregroundStyle(Color.mainText) + + Text(content) + .font(.pretendard(.regular, size: 15)) + .foregroundStyle(Color.mainText) + } + } +} + +// MARK: EnojoyRateView +private extension HomeDiaryEditView { + func EnjoyRateView(store: StoreOf) -> some View { + HStack { + Text("재미도") + .font(.pretendard(.bold, size: 15)) + .foregroundStyle(Color.mainText) + Spacer() + EnjoyCountView(store: store) + } + } + + func EnjoyCountView(store: StoreOf) -> some View { + HStack(spacing: 4) { + ForEach(0..<3) { index in + let isFilled = index < store.diary.enjoyRating + Image(asset: isFilled ? SharedDesignSystemAsset.Assets.icHeartSelected : SharedDesignSystemAsset.Assets.icHeart) + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .onTapGesture { + store.send(.tapEnjoyRating(index + 1), animation: .default) + } + } + } + } +} + +// MARK: ContentView +private extension HomeDiaryEditView { + + func ContentView(store: StoreOf) -> some View { + VStack(spacing: 10) { + ContentInputView(store: store) + ContentFooterView( + count: store.diary.content.count, + maxCount: HomeDiaryEditStore.contentLimit, + isValid: store.isContentValid + ) + } + } + + func ContentInputView(store: StoreOf) -> some View { + HStack(spacing: 0) { + // 좌측 고정 빨간 박스 + Rectangle() + .fill(Color.namoPink) + .frame(width: 10) + // 다중 줄 텍스트 입력 + TextEditor(text: Binding(get: { store.diary.content }, set: { store.send(.typeContent($0)) })) + .font(.pretendard(.regular, size: 14)) + .foregroundStyle(Color.mainText) + .scrollContentBackground(.hidden) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .overlay( + Text("내용 입력") + .font(.pretendard(.bold, size: 14)) + .foregroundStyle(Color.textUnselected) + .opacity(store.diary.content.isEmpty ? 1 : 0) + .padding(.vertical, 16) + .padding(.horizontal, 16), + alignment: .topLeading + ) + } + .frame(height: 150) + .background(Color.itemBackground) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + func ContentFooterView(count: Int, maxCount: Int, isValid: Bool) -> some View { + HStack { + Spacer() + Text("\(count) / \(maxCount)") + .font(.pretendard(.bold, size: 12)) + .foregroundStyle(isValid ? Color.textUnselected : Color.mainOrange) + } + } +} + +// MARK: DiaryImageView +private extension HomeDiaryEditView { + + func DiaryImageView(store: StoreOf) -> some View { + HStack(spacing: 15) { + ForEach(Array(store.selectedImages.enumerated()), id: \.self.element) { (offset, imageData) in + DiaryImageListItemView( + image: UIImage(data: imageData) ?? UIImage(), + index: offset, + store: store + ) + } + + PhotosPicker(selection: Binding(get: { store.selectedItems }, set: { + newItems in + guard let item = newItems.last else { return } + store.send(.selectPhoto(item)) + }), maxSelectionCount: 1) { + + if store.selectedImages.count < 3 { + Image(asset: SharedDesignSystemAsset.Assets.noPicture) + .resizable() + .frame(width: 92, height: 92) + } + } + } + } + + func DiaryImageListItemView(image: UIImage, index: Int, store: StoreOf) -> some View { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: 92, height: 92) + .overlay(alignment: .topTrailing) { + Circle() + .frame(width: 20, height: 20) + .foregroundStyle(.white) + .overlay { + Image(asset: SharedDesignSystemAsset.Assets.icXmark) + } + .offset(x: 10, y: -10) + .onTapGesture { + store.send(.deleteImage(index), animation: .default) + } + } + } +} diff --git a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/Contents.json b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/Contents.json index 0e8d1f5d..453b9299 100644 --- a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/Contents.json +++ b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "기록 1.png", + "filename" : "noPic.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "기록@2x 1.png", + "filename" : "noPic@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "기록@3x 1.png", + "filename" : "noPic@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235 1.png" b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/noPic.png similarity index 100% rename from "Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235 1.png" rename to Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/noPic.png diff --git "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@2x 1.png" b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/noPic@2x.png similarity index 100% rename from "Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@2x 1.png" rename to Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/noPic@2x.png diff --git "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@3x 1.png" b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/noPic@3x.png similarity index 100% rename from "Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@3x 1.png" rename to Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/noPic@3x.png diff --git "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235.png" "b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235.png" deleted file mode 100644 index 2fcddbac..00000000 Binary files "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235.png" and /dev/null differ diff --git "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@2x.png" "b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@2x.png" deleted file mode 100644 index f2a5aff3..00000000 Binary files "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@2x.png" and /dev/null differ diff --git "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@3x.png" "b/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@3x.png" deleted file mode 100644 index 6965edf0..00000000 Binary files "a/Namo_SwiftUI/Projects/Shared/DesignSystem/Resources/Assets.xcassets/NewImage/noPicture.imageset/\352\270\260\353\241\235@3x.png" and /dev/null differ diff --git a/Namo_SwiftUI/Projects/Shared/DesignSystem/Sources/Components/NavBar/NamoNabBarModifier.swift b/Namo_SwiftUI/Projects/Shared/DesignSystem/Sources/Components/NavBar/NamoNabBarModifier.swift index c0b08b56..c51bfff2 100644 --- a/Namo_SwiftUI/Projects/Shared/DesignSystem/Sources/Components/NavBar/NamoNabBarModifier.swift +++ b/Namo_SwiftUI/Projects/Shared/DesignSystem/Sources/Components/NavBar/NamoNabBarModifier.swift @@ -39,11 +39,11 @@ public struct NamoNavBarModifier: ViewModifier where C: View, L: View, } .frame(height: 52) - Spacer() + Spacer(minLength: 0) content - Spacer() + Spacer(minLength: 0) } .background(.white) .navigationBarHidden(true) diff --git a/Namo_SwiftUI/Projects/Shared/Util/Sources/Model/NamoAlertContent.swift b/Namo_SwiftUI/Projects/Shared/Util/Sources/Model/NamoAlertContent.swift new file mode 100644 index 00000000..9fc7d5d0 --- /dev/null +++ b/Namo_SwiftUI/Projects/Shared/Util/Sources/Model/NamoAlertContent.swift @@ -0,0 +1,16 @@ +// +// NamoAlertContent.swift +// SharedUtil +// +// Created by 박민서 on 11/13/24. +// + +public struct NamoAlertContent: Equatable { + public var title: String + public var message: String + + public init(title: String = "", message: String = "") { + self.title = title + self.message = message + } +}