diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 25b486c..40506e3 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -25,6 +25,7 @@ let project = Project.configure( "CFBundleVersion": "1", "UILaunchStoryboardName": "launch", "NSUserTrackingUsageDescription": "이 앱은 사용자 맞춤형 광고 제공 및 분석을 위해 사용자 추적 정보를 수집합니다.", + "NSPhotoLibraryUsageDescription": "밈 이미지 등록을 위해 앨범 접근 권한이 필요합니다.", "UIUserInterfaceStyle": "Light", // 다크모드 방지 "NSAllowArbitraryLoads": true, "NSAppTransportSecurity": [ @@ -75,6 +76,7 @@ let project = Project.configure( "UILaunchStoryboardName": "launch", "NSUserTrackingUsageDescription": "이 앱은 사용자 맞춤형 광고 제공 및 분석을 위해 사용자 추적 정보를 수집합니다.", "UIUserInterfaceStyle": "Light", // 다크모드 방지 + "NSPhotoLibraryUsageDescription": "밈 이미지 등록을 위해 앨범 접근 권한이 필요합니다.", "NSAppTransportSecurity": [ "NSAllowsArbitraryLoads": true ] diff --git a/Projects/Core/DesignSystem/Sources/Extension/View+Extension.swift b/Projects/Core/DesignSystem/Sources/Extension/View+Extension.swift index 8ca522e..6ceaa19 100644 --- a/Projects/Core/DesignSystem/Sources/Extension/View+Extension.swift +++ b/Projects/Core/DesignSystem/Sources/Extension/View+Extension.swift @@ -16,6 +16,31 @@ public extension View { ) -> some View { clipShape(RoundedCorners(radius: radius, corners: corners)) } + + // MARK: - Keyboard + func endTextEditing() { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + + func onKeyboardChange(_ action: @escaping (Bool) -> Void) -> some View { + self.onAppear { + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in + action(true) + } + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in + action(false) + } + } + .onDisappear { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } + } } public extension View { @@ -29,3 +54,5 @@ public extension View { ) } } + + diff --git a/Projects/Core/DesignSystem/Sources/KeywordsTagView.swift b/Projects/Core/DesignSystem/Sources/KeywordsTagView.swift deleted file mode 100644 index 5d05ddd..0000000 --- a/Projects/Core/DesignSystem/Sources/KeywordsTagView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// KeywordsTagView.swift -// DesignSystem -// -// Created by 리나 on 2024/06/29. -// - -import SwiftUI -import ResourceKit - -// thanks to NamS -public struct KeywordsTagView: View { - @State public var keywords: [String] - var onTapHandler: ((String) -> ())? - - public init(keywords: [String], onTapHandler: ((String) -> ())?) { - self.keywords = keywords - self.onTapHandler = onTapHandler - } - - public var body: some View { - ScrollView { - CategoryTagLayout(verticalSpacing: 8, horizontalSpacing: 8) { - ForEach(keywords, id: \.self) { keyword in - Text(keyword) - .font(Font.Body.Medium.medium) - .foregroundColor(Color.Text.primary) - .padding(.horizontal, 16) - .padding(.vertical, 9.5) - .background( - Capsule().foregroundStyle(Color.Background.assistive) - ) - .onTapGesture { - onTapHandler?(keyword) - } - } - } - } - .onAppear { - // tagView 사이즈를 잰 후 다시 그리기 위함 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - let cacheValue = keywords - keywords = [] - keywords = cacheValue - } - } - } -} - -struct CategoryTagLayout: Layout { - var verticalSpacing: CGFloat = 0 - var horizontalSpacing: CGFloat = 0 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { - CGSize(width: proposal.width ?? 0, height: cache.height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { - var sumX: CGFloat = bounds.minX - var sumY: CGFloat = bounds.minY - - for index in subviews.indices { - let view = subviews[index] - let viewSize = view.sizeThatFits(.unspecified) - guard let proposalWidth = proposal.width else { continue } - - if (sumX + viewSize.width > proposalWidth) { - sumX = bounds.minX - sumY += viewSize.height - sumY += verticalSpacing - } - - let point = CGPoint(x: sumX, y: sumY) - view.place(at: point, anchor: .topLeading, proposal: proposal) - sumX += viewSize.width - sumX += horizontalSpacing - } - - if let firstViewSize = subviews.first?.sizeThatFits(.unspecified) { - cache.height = sumY + firstViewSize.height - } - } - - struct Cache { - var height: CGFloat - } - - func makeCache(subviews: Subviews) -> Cache { - return Cache(height: 0) - } - - func updateCache(_ cache: inout Cache, subviews: Subviews) { } -} diff --git a/Projects/Core/DesignSystem/Sources/View/FarmemeAlertView.swift b/Projects/Core/DesignSystem/Sources/View/FarmemeAlertView.swift new file mode 100644 index 0000000..92dcc21 --- /dev/null +++ b/Projects/Core/DesignSystem/Sources/View/FarmemeAlertView.swift @@ -0,0 +1,75 @@ +// +// FarmemeAlertView.swift +// DesignSystem +// +// Created by 장혜령 on 9/29/24. +// + +import SwiftUI + +import ResourceKit + +public struct FarmemeAlertView: View { + private let title: String + private let description: String + private var dismiss: (() -> Void) + + public init(title: String, + description: String, + dismiss: @escaping (() -> Void) + ) { + self.title = title + self.description = description + self.dismiss = dismiss + } + + public var body: some View { + VStack(spacing: 0) { + titleView + + descriptionView + .padding(.top, 8) + + confirmButton + .padding(.top, 14) + } + .padding(.horizontal, 30) + .padding(.vertical, 20) + .background(Color.Background.white) + .cornerRadius(20) + } + + + private var titleView: some View { + HStack { + Text(title) + .font(Font.Heading.Medium.semiBold) + .foregroundColor(Color.Text.primary) + .foregroundColor(.black) + Spacer() + } + } + + private var descriptionView: some View { + HStack { + Text(description) + .font(Font.Body.Large.medium) + .foregroundColor(Color.Text.secondary) + .multilineTextAlignment(.leading) + Spacer() + } + } + + private var confirmButton: some View { + HStack{ + Spacer() + Button { + dismiss() + } label: { + Text("확인") + .font(Font.Body.Large.medium) + .foregroundColor(Color.Text.brand) + } + } + } +} diff --git a/Projects/Core/DesignSystem/Sources/View/Keyword/KeywordTag.swift b/Projects/Core/DesignSystem/Sources/View/Keyword/KeywordTag.swift new file mode 100644 index 0000000..c804439 --- /dev/null +++ b/Projects/Core/DesignSystem/Sources/View/Keyword/KeywordTag.swift @@ -0,0 +1,20 @@ +// +// KeywordTag.swift +// DesignSystem +// +// Created by 장혜령 on 9/24/24. +// + +import Foundation + +public struct KeywordTag: Hashable, Identifiable { + public let id: String + public let name: String + public let isSelected: Bool + + public init(id: String, name: String, isSelected: Bool = false) { + self.id = id + self.name = name + self.isSelected = isSelected + } +} diff --git a/Projects/Core/DesignSystem/Sources/View/Keyword/KeywordsTagView.swift b/Projects/Core/DesignSystem/Sources/View/Keyword/KeywordsTagView.swift new file mode 100644 index 0000000..ad04be9 --- /dev/null +++ b/Projects/Core/DesignSystem/Sources/View/Keyword/KeywordsTagView.swift @@ -0,0 +1,141 @@ +// +// KeywordsTagView.swift +// DesignSystem +// +// Created by 리나 on 2024/06/29. +// + +import SwiftUI +import ResourceKit + +// thanks to NamS +public struct KeywordsTagView: View { + let keywordTags: [KeywordTag] + var onTapHandler: ((String) -> ())? + + public init(keywordTags: [KeywordTag], onTapHandler: ((String) -> ())?) { + self.keywordTags = keywordTags + self.onTapHandler = onTapHandler + } + + public var body: some View { + ScrollView { + FlowLayout(spacing: 8, lineSpacing: 8) { + ForEach(keywordTags, id: \.self) { keywordTag in + Text(keywordTag.name) + .font(Font.Body.Medium.medium) + .foregroundColor( + keywordTag.isSelected + ? Color.Text.brand + : Color.Text.primary + ) + .padding(.horizontal, 16) + .padding(.vertical, 9.5) + .background( + Capsule().foregroundStyle( + keywordTag.isSelected + ? Color.Background.brandassistive + : Color.Background.assistive + ) + ) + .onTapGesture { + onTapHandler?(keywordTag.name) + } + } + } + } + } +} + +struct FlowLayout: Layout { + var spacing: CGFloat? + var lineSpacing: CGFloat + + init(spacing: CGFloat? = nil, lineSpacing: CGFloat) { + self.spacing = spacing + self.lineSpacing = lineSpacing + } + + struct Cache { + var sizes: [CGSize] = [] + var spacing: [CGFloat] = [] + } + + func makeCache(subviews: Subviews) -> Cache { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let spacing: [CGFloat] = subviews.indices.map { index in + guard index != subviews.count - 1 else { + return 0 + } + + return subviews[index].spacing.distance( + to: subviews[index+1].spacing, + along: .horizontal + ) + } + + return Cache(sizes: sizes, spacing: spacing) + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + var totalHeight = 0.0 + var totalWidth = 0.0 + + var lineWidth = 0.0 + var lineHeight = 0.0 + + for index in subviews.indices { + if lineWidth + cache.sizes[index].width > proposal.width ?? 0 { + totalHeight += lineHeight + lineSpacing // 줄 간격 추가 + lineWidth = cache.sizes[index].width + lineHeight = cache.sizes[index].height + } else { + lineWidth += cache.sizes[index].width + (spacing ?? cache.spacing[index]) + lineHeight = max(lineHeight, cache.sizes[index].height) + } + + totalWidth = max(totalWidth, lineWidth) + } + + totalHeight += lineHeight + + return .init(width: totalWidth, height: totalHeight) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + var lineX = bounds.minX + var lineY = bounds.minY + var lineHeight: CGFloat = 0 + + for index in subviews.indices { + if lineX + cache.sizes[index].width > (proposal.width ?? 0) { + lineY += lineHeight + lineSpacing // 줄 간격 추가 + lineHeight = 0 + lineX = bounds.minX + } + + let position = CGPoint( + x: lineX + cache.sizes[index].width / 2, + y: lineY + cache.sizes[index].height / 2 + ) + + lineHeight = max(lineHeight, cache.sizes[index].height) + lineX += cache.sizes[index].width + (spacing ?? cache.spacing[index]) + + subviews[index].place( + at: position, + anchor: .center, + proposal: ProposedViewSize(cache.sizes[index]) + ) + } + } +} diff --git a/Projects/Core/DesignSystem/Sources/MemeCategoryView.swift b/Projects/Core/DesignSystem/Sources/View/Keyword/MemeCategoryView.swift similarity index 81% rename from Projects/Core/DesignSystem/Sources/MemeCategoryView.swift rename to Projects/Core/DesignSystem/Sources/View/Keyword/MemeCategoryView.swift index 4cb5b3b..a96cf96 100644 --- a/Projects/Core/DesignSystem/Sources/MemeCategoryView.swift +++ b/Projects/Core/DesignSystem/Sources/View/Keyword/MemeCategoryView.swift @@ -10,16 +10,16 @@ import ResourceKit public struct MemeCategoryView: View { public let category: String - public let keywords: [String] + public let keywordTags: [KeywordTag] public let onTapHandler: ((String) -> ())? public init( category: String, - keywords: [String], + keywordTags: [KeywordTag], onTapHandler: ((String) -> ())? ) { self.category = category - self.keywords = keywords + self.keywordTags = keywordTags self.onTapHandler = onTapHandler } @@ -36,7 +36,7 @@ public struct MemeCategoryView: View { .padding(.bottom, 16) .padding(.horizontal, 20) - KeywordsTagView(keywords: keywords, onTapHandler: onTapHandler) + KeywordsTagView(keywordTags: keywordTags, onTapHandler: onTapHandler) .padding(.horizontal, 20) .padding(.bottom, 20) } diff --git a/Projects/Core/PPACData/Sources/Endpoint/MemeEditEndPoint.swift b/Projects/Core/PPACData/Sources/Endpoint/MemeEditEndPoint.swift new file mode 100644 index 0000000..8fe7859 --- /dev/null +++ b/Projects/Core/PPACData/Sources/Endpoint/MemeEditEndPoint.swift @@ -0,0 +1,60 @@ +// +// MemeEditEndPoint.swift +// PPACData +// +// Created by 장혜령 on 9/27/24. +// + +import Foundation +import PPACNetwork +import UIKit + +enum MemeEditEndPoint: MultipartRequestable { + case registerMeme(formData: FormData, title: String, source: String, keywordIds: [String]) + + var httpMethod: HTTPMethod { + switch self { + case .registerMeme: + return .post + } + } + + var path: String? { + switch self { + case .registerMeme: + return "meme" + } + } + + var headers: [String : String]? { + return nil + } + + var parameter: HTTPRequestParameter? { + return nil + } + + var formData: MultipartFormData { + switch self { + case .registerMeme(let formData, let title, let source, let keywordIds): + let formFields: [String : String] = ["title" : title, + "source": source] + print("=============== MultipartFormData ===============\n") + var multipartFormData = MultipartFormData() + + formFields.forEach { key, value in + multipartFormData.appendTextField(named: key, value: value) + } + + for keyword in keywordIds { + multipartFormData.appendTextField(named: "keywordIds[]", value: keyword) + } + + multipartFormData.appendFormData(formData: formData) + multipartFormData.appendFinalBoundary() + print("=============== END ===============") + return multipartFormData + } + } + +} diff --git a/Projects/Core/PPACData/Sources/Repository/KeywordRepositoryImpl.swift b/Projects/Core/PPACData/Sources/Repository/KeywordRepositoryImpl.swift index ef99c2b..a146bc6 100644 --- a/Projects/Core/PPACData/Sources/Repository/KeywordRepositoryImpl.swift +++ b/Projects/Core/PPACData/Sources/Repository/KeywordRepositoryImpl.swift @@ -43,7 +43,12 @@ public final class KeywordRepositoryImpl: KeywordRepository { case .success(let data): guard let memeCategoryData = data.data else { throw NetworkError.dataDecodingError } let memeCategorys = memeCategoryData - .compactMap { MemeCategory(category: $0.category, keywords: $0.keywords.map { $0.name }) } + .compactMap { + MemeCategory( + category: $0.category, + keywords: $0.keywords.map { MemeKeyword(id: $0._id, name: $0.name) } + ) + } return memeCategorys case .failure(let error): throw error diff --git a/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift b/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift index af3d27d..c55da72 100644 --- a/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift +++ b/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift @@ -124,5 +124,29 @@ public class MemeRepositoryImpl: MemeRepository { throw failure } } + + public func registerMeme( + formData: FormData, + title: String, + source: String, + keywordIds: [String] + ) async throws { + let endPoint = MemeEditEndPoint.registerMeme( + formData: formData, + title: title, + source: source, + keywordIds: keywordIds + ) + + let result = await networkservice.request(endPoint, dataType: BaseDTO.self) + + switch result { + case .success: + return + case .failure(let failure): + throw failure + } + + } } diff --git a/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift b/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift index 44f3c63..a3a7eef 100644 --- a/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift +++ b/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift @@ -8,6 +8,7 @@ import Foundation import PPACModels +import PPACNetwork public protocol MemeRepository { @@ -19,4 +20,6 @@ public protocol MemeRepository { func shareMeme(memeId: String) async throws func watchMeme(memeId: String, type: String) async throws func reactToMeme(memeId: String) async throws + + func registerMeme(formData: FormData, title: String, source: String, keywordIds: [String]) async throws } diff --git a/Projects/Core/PPACDomain/Sources/UseCase/Meme/RegisterMemeUseCase.swift b/Projects/Core/PPACDomain/Sources/UseCase/Meme/RegisterMemeUseCase.swift new file mode 100644 index 0000000..8bed93a --- /dev/null +++ b/Projects/Core/PPACDomain/Sources/UseCase/Meme/RegisterMemeUseCase.swift @@ -0,0 +1,31 @@ +// +// RegisterMemeUseCase.swift +// PPACDomain +// +// Created by 장혜령 on 9/29/24. +// + +import Foundation + +import PPACNetwork + +public protocol RegisterMemeUseCase { + func execute(formData: FormData, title: String, source: String, keywordIds: [String]) async throws +} + +public class RegisterMemeUseCaseImpl: RegisterMemeUseCase { + private let repository: MemeRepository + + public init(repository: MemeRepository) { + self.repository = repository + } + + public func execute(formData: FormData, title: String, source: String, keywordIds: [String]) async throws { + try await repository.registerMeme( + formData: formData, + title: title, + source: source, + keywordIds: keywordIds) + + } +} diff --git a/Projects/Core/PPACModels/Sources/Search/MemeCategory.swift b/Projects/Core/PPACModels/Sources/Search/MemeCategory.swift index 4d5d125..b488ba6 100644 --- a/Projects/Core/PPACModels/Sources/Search/MemeCategory.swift +++ b/Projects/Core/PPACModels/Sources/Search/MemeCategory.swift @@ -10,11 +10,22 @@ import Foundation public struct MemeCategory: Hashable, Identifiable { public let id = UUID() public let category: String - public let keywords: [String] + public var keywords: [MemeKeyword] - public init(category: String, keywords: [String]) { + public init(category: String, keywords: [MemeKeyword]) { self.category = category self.keywords = keywords } } +public struct MemeKeyword: Hashable, Identifiable { + public let id: String + public let name: String + public var isSelected: Bool + + public init(id: String, name: String, isSelected: Bool = false) { + self.id = id + self.name = name + self.isSelected = isSelected + } +} diff --git a/Projects/Core/PPACNetwork/Sources/MultipartFormData.swift b/Projects/Core/PPACNetwork/Sources/MultipartFormData.swift new file mode 100644 index 0000000..0835d16 --- /dev/null +++ b/Projects/Core/PPACNetwork/Sources/MultipartFormData.swift @@ -0,0 +1,122 @@ +// +// MultipartFormData.swift +// PPACNetwork +// +// Created by 장혜령 on 9/25/24. +// + +import Foundation + +enum EncodingCharacters { + static let crlf = "\r\n" +} + +struct BoundaryGenerator { + + enum BoundaryType { + case initial, encapsulated, final + } + + // MARK: - properties + let boundary: String + + // MARK: - init + init(boundary: String) { + self.boundary = boundary + } + + public func boundaryData( + forBoundaryType boundaryType: BoundaryType + ) -> String { + let boundaryText: String + + switch boundaryType { + case .initial: + boundaryText = "--\(boundary)\(EncodingCharacters.crlf)" + case .encapsulated: + //boundaryText = "\(EncodingCharacters.crlf)--\(boundary)\(EncodingCharacters.crlf)" + boundaryText = "--\(boundary)\(EncodingCharacters.crlf)" + case .final: + //boundaryText = "\(EncodingCharacters.crlf)--\(boundary)--\(EncodingCharacters.crlf)" + boundaryText = "--\(boundary)--\(EncodingCharacters.crlf)" + } + + return boundaryText + } +} + +public struct FormData { + public var fieldName: String + public var fileName: String + public var mimeType: String + public var fileData: Data + + public init( + fieldName: String, + fileName: String, + mimeType: String, + fileData: Data + ) { + self.fieldName = fieldName + self.fileName = fileName + self.mimeType = mimeType + self.fileData = fileData + } +} + +public struct MultipartFormData { + public typealias FormField = [String: String] + + private let boundary: String + private let boundaryGenerator: BoundaryGenerator + public var body = Data() + + public init( + boundary: String = UUID().uuidString, + formFields: FormField = [:], + formData: FormData? = nil + ) { + self.boundary = boundary + self.boundaryGenerator = BoundaryGenerator(boundary: boundary) + } + + public var contentType: String { + return "multipart/form-data; boundary=\(boundary)" + } + + public mutating func appendFinalBoundary() { + self.body.append(boundaryGenerator.boundaryData(forBoundaryType: .final)) + } + + public func finalize() -> Data { + return body + } + + public mutating func appendTextField(named name: String, value: String) { + var data = Data() + data.append(boundaryGenerator.boundaryData(forBoundaryType: .encapsulated)) + data.append("Content-Disposition: form-data; name=\"\(name)\"\(EncodingCharacters.crlf)\(EncodingCharacters.crlf)") + data.append("\(value)\(EncodingCharacters.crlf)") + self.body.append(data) + } + + public mutating func appendFormData(formData: FormData) { + var data = Data() + data.append(boundaryGenerator.boundaryData(forBoundaryType: .encapsulated)) + data.append("Content-Disposition: form-data; name=\"\(formData.fieldName)\"; filename=\"\(formData.fileName)\"\(EncodingCharacters.crlf)") + data.append("Content-Type: \(formData.mimeType)\(EncodingCharacters.crlf)\(EncodingCharacters.crlf)") + data.append(formData.fileData) + print(formData.fileData) + data.append(EncodingCharacters.crlf) + self.body.append(data) + } +} + +extension Data { + mutating func append(_ string: String) { + if let data = string.data(using: .utf8) { + print(string) + self.append(data) + } + } +} diff --git a/Projects/Core/PPACNetwork/Sources/MultipartRequestable.swift b/Projects/Core/PPACNetwork/Sources/MultipartRequestable.swift new file mode 100644 index 0000000..147732c --- /dev/null +++ b/Projects/Core/PPACNetwork/Sources/MultipartRequestable.swift @@ -0,0 +1,12 @@ +// +// MultipartRequestable.swift +// PPACNetwork +// +// Created by 장혜령 on 9/27/24. +// + +import Foundation + +public protocol MultipartRequestable: Requestable { + var formData: MultipartFormData { get } +} diff --git a/Projects/Core/PPACNetwork/Sources/NetworkService.swift b/Projects/Core/PPACNetwork/Sources/NetworkService.swift index b6f5518..15387e9 100644 --- a/Projects/Core/PPACNetwork/Sources/NetworkService.swift +++ b/Projects/Core/PPACNetwork/Sources/NetworkService.swift @@ -12,21 +12,47 @@ final public class NetworkService: NetworkServiceable { public init() {} public func request(_ request: Requestable, dataType: T.Type) async -> Result { - guard let url = request.makeURL() else { NetworkLogger.logError(.urlEncodingError) return .failure(.urlEncodingError) } - let urlRequest = request.buildURLRequest(with: url) - let (data, response): (Data, URLResponse) + var urlRequest = request.buildURLRequest(with: url) + + + if let multipartRequest = request as? MultipartRequestable { + let multipartFormData = multipartRequest.formData + urlRequest.setValue("*/*", forHTTPHeaderField: "Accept") + urlRequest.setValue(multipartFormData.contentType, forHTTPHeaderField: "Content-Type") + return await executeUploadRequest(urlRequest, multipartFormData.finalize(), dataType: dataType) + } else { + return await executeRequest(urlRequest, dataType: dataType) + } + } + + private func executeRequest(_ urlRequest: URLRequest, dataType: T.Type) async -> Result { do { - (data, response) = try await URLSession.shared.data(for: urlRequest) - } catch(let error) { + NetworkLogger.logRequest(urlRequest) + let (data, response) = try await URLSession.shared.data(for: urlRequest) + return handleResponse(response, data: data, dataType: dataType) + } catch let error { NetworkLogger.logError(.invalidResponse, message: "\(error)") return .failure(.invalidResponse) } - + } + + private func executeUploadRequest(_ urlRequest: URLRequest, _ bodyData: Data, dataType: T.Type) async -> Result { + do { + NetworkLogger.logRequest(urlRequest) + let (data, response) = try await URLSession.shared.upload(for: urlRequest, from: bodyData) + return handleResponse(response, data: data, dataType: dataType) + } catch let error { + NetworkLogger.logError(.invalidResponse, message: "\(error)") + return .failure(.invalidResponse) + } + } + + private func handleResponse(_ response: URLResponse, data: Data, dataType: T.Type) -> Result { guard let httpResponse = response as? HTTPURLResponse else { NetworkLogger.logError(.invalidResponse) return .failure(.invalidResponse) @@ -55,11 +81,4 @@ final public class NetworkService: NetworkServiceable { return .failure(error) } - private func printJson(data: Data) { - if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), - let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]), - let prettyString = String(data: prettyData, encoding: .utf8) { - debugPrint("Response JSON:\n\(prettyString)") - } - } } diff --git a/Projects/Core/PPACNetwork/Sources/Requestable.swift b/Projects/Core/PPACNetwork/Sources/Requestable.swift index 2b1f295..d1c4f5c 100644 --- a/Projects/Core/PPACNetwork/Sources/Requestable.swift +++ b/Projects/Core/PPACNetwork/Sources/Requestable.swift @@ -33,7 +33,8 @@ public protocol Requestable { extension Requestable { private var baseUrl: String { - return "https://ppac-server-goorm.run.goorm.site/api" + return "http://ppac-server.run.goorm.io/api/" // 개발 서버 + //return "https://ppac-server-goorm.run.goorm.site/api" // 운영 서버 } public func makeURL() -> URL? { @@ -46,7 +47,7 @@ extension Requestable { urlRequest.httpMethod = httpMethod.rawValue.uppercased() var defaultHeaders = [ - "x-device-id": UserInfo.shared.deviceId, + "x-device-id": UserInfo.shared.testDeviceId, "accept": "application/json", "Content-Type": "application/json" ] diff --git a/Projects/Core/PPACNetwork/Sources/Utils/NetworkLogger.swift b/Projects/Core/PPACNetwork/Sources/Utils/NetworkLogger.swift index adab11c..5e05350 100644 --- a/Projects/Core/PPACNetwork/Sources/Utils/NetworkLogger.swift +++ b/Projects/Core/PPACNetwork/Sources/Utils/NetworkLogger.swift @@ -11,7 +11,7 @@ class NetworkLogger { static func logRequest(_ request: URLRequest) { print("➡️ [REQUEST]: \(request.httpMethod ?? "N/A") \(request.url?.absoluteString ?? "")") if let headers = request.allHTTPHeaderFields { - print("📝 [HEADERS]: \(headers)") + print("📝 [REQUEST HEADERS]: \(headers)") } if let body = request.httpBody { print("📦 [BODY]: \(String(data: body, encoding: .utf8) ?? "N/A")") diff --git a/Projects/Features/MemeDetail/Sources/MemeDetailViewModel.swift b/Projects/Features/MemeDetail/Sources/MemeDetailViewModel.swift index 7b34252..6248497 100644 --- a/Projects/Features/MemeDetail/Sources/MemeDetailViewModel.swift +++ b/Projects/Features/MemeDetail/Sources/MemeDetailViewModel.swift @@ -175,8 +175,14 @@ private extension MemeDetailViewModel { @MainActor func showShareSheet() async { - let deeplinkUrl = "https://farmeme.onelink.me/RtpU/y09dosru?deep_link_value=\(self.state.meme.id)" - self.router?.showShareView(items: [deeplinkUrl]) - self.logMemeDetail(event: .share) + do { + let deeplinkUrl = "https://farmeme.onelink.me/RtpU/y09dosru?deep_link_value=\(self.state.meme.id)" + self.router?.showShareView(items: [deeplinkUrl]) + try await self.shareMemeUseCase.execute(memeId: state.meme.id) + self.logMemeDetail(event: .share) + } catch { + // TODO: - 에러처리 + print(error) + } } } diff --git a/Projects/Features/MemeEditor/Project.swift b/Projects/Features/MemeEditor/Project.swift new file mode 100644 index 0000000..26b0ec3 --- /dev/null +++ b/Projects/Features/MemeEditor/Project.swift @@ -0,0 +1,30 @@ +// +// Project.swift +// MemeEditor +// +// Created by hyeryeong on 9/20/24 +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project( + name: "MemeEditor", + targets: [ + .configure( + name: "MemeEditor", + product: .framework, + infoPlist: .default, + sources: "Sources/**", + resources: "Resources/**", + dependencies: [ + .ThirdParty.Dependency, + .ResourceKit, + .Core.DesignSystem, + .Core.PPACModels, + .Core.PPACAnalytics + ] + ) + ] +) + diff --git a/Projects/Features/MemeEditor/Resources/dummy.swift b/Projects/Features/MemeEditor/Resources/dummy.swift new file mode 100644 index 0000000..1f7ee25 --- /dev/null +++ b/Projects/Features/MemeEditor/Resources/dummy.swift @@ -0,0 +1 @@ +//더미임미다 diff --git a/Projects/Features/MemeEditor/Sources/ImageEditView.swift b/Projects/Features/MemeEditor/Sources/ImageEditView.swift new file mode 100644 index 0000000..96d2d0c --- /dev/null +++ b/Projects/Features/MemeEditor/Sources/ImageEditView.swift @@ -0,0 +1,186 @@ +// +// ImageEditView.swift +// MemeEditor +// +// Created by 장혜령 on 9/24/24. +// + +import SwiftUI + +import ResourceKit +import DesignSystem + +import Kingfisher + +public struct ImageEditView: View { + + private let imageUrl: String + @State private var selectedImage: UIImage? + private var onImageSelectionCompleted: ((UIImage?) -> ())? + + @Environment(\.screenSize) var screenSize + @State private var showImagePicker = false + @State private var imageSize: CGSize = .zero + private var imageWidth: CGFloat { + return screenSize.width - Constants.horizantalPadding * 2 + } + + enum Constants { + static let horizantalPadding: CGFloat = 92 + static let verticalPadding: CGFloat = 48 + static let totalHeight: CGFloat = 330 + static let imageHeight = totalHeight - verticalPadding * 2 + } + + public init( + imageUrl: String, + onImageSelectionCompleted: ((UIImage?) -> ())? = nil + ) { + self.imageUrl = imageUrl + self.onImageSelectionCompleted = onImageSelectionCompleted + } + + public var body: some View { + VStack { + if let _ = selectedImage { + imageViewWithButton + } else { + emptyImageRegisterView + } + } + .padding(.horizontal, Constants.horizantalPadding) + .padding(.vertical, Constants.verticalPadding) + .frame(height: Constants.totalHeight) + .sheet(isPresented: $showImagePicker) { + ImagePicker(selectedImage: $selectedImage) + } + .onChange(of: selectedImage) { oldImage, newImage in + updateImageSize(newImage?.size ?? .zero) + onImageSelectionCompleted?(newImage) + } + } + + private var emptyImageRegisterView: some View { + ZStack { + emptyBackgroundView + imageRegisterChipView + } + .onTapGesture { + showImagePicker = true + } + } + + private var emptyBackgroundView: some View { + RoundedRectangle(cornerRadius: 20) + .stroke( + Color.Border.tertiary, + lineWidth: 2, + fill: Color.Background.assistive) + } + + private var imageRegisterChipView: some View { + HStack { + ResourceKitAsset.Icon.album.swiftUIImage + .resizable() + .frame(width: 20, height: 20) + RequiredTitleView(title: "이미지 등록") + } + .padding(12) + .background { + RoundedRectangle(cornerRadius: 25) + .foregroundStyle(Color.Background.white) + } + } + + var imageViewWithButton: some View { + ZStack(alignment: .bottomTrailing) { + imageView + imageEidtCircleView + } + } + + var imageView: some View { + ZStack(alignment: .center) { + RoundedRectangle(cornerRadius: 20) + .stroke( + Color.Border.primary, + lineWidth: 2, + fill: Color.Background.primary) + .frame(width: imageWidth, height: Constants.imageHeight) + Image(uiImage: selectedImage) + .resizable() + .scaledToFit() + .frame(width: imageSize.width, height: imageSize.height) + } + } + + var kfImageViewWithButton: some View { + ZStack(alignment: .bottomTrailing) { + kfImageView + imageEidtCircleView + } + } + + var imageEidtCircleView: some View { + CircleButton( + width: 50, + height: 50, + image: ResourceKitAsset.Icon.album.swiftUIImage, + shadowColor: Color.Shadow.orange, + action: { showImagePicker = true } + ) + .padding(.trailing, 12) + .padding(.bottom, 12) + } + + var kfImageView: some View { + ZStack(alignment: .center) { + RoundedRectangle(cornerRadius: 20) + .stroke( + Color.Border.primary, + lineWidth: 2, + fill: Color.Background.primary) + KFImage(URL(string: imageUrl)) + .resizable() + .loadDiskFileSynchronously() + .cacheMemoryOnly() + .onSuccess { result in + guard result.image.size.width > 0 else { return } + updateImageSize(result.image.size) + } + .frame(width: imageSize.width, height: imageSize.height) + } + } + + func updateImageSize(_ size: CGSize) { + let imageRatio: CGFloat + if size.width > size.height { + imageRatio = imageWidth / size.width + imageSize = CGSize( + width: imageWidth, + height: size.height * imageRatio + ) + } else { + imageRatio = Constants.imageHeight / size.height + imageSize = CGSize( + width: size.width * imageRatio, + height: Constants.imageHeight + ) + } + } +} + +#Preview { + ScrollView { + VStack { + // 이미지가 없을 때 + ImageEditView(imageUrl: "") + // 가로가 긴 이미지 + ImageEditView(imageUrl: "https://png.pngtree.com/thumb_back/fh260/background/20230617/pngtree-road-and-trees-lead-to-the-mountains-image_2972647.jpg") + // 세로가 긴 이미지 + ImageEditView(imageUrl: "https://thumb.ac-illust.com/37/37405b48206e100357550676fd124a8f_t.jpeg") + } + } + + +} diff --git a/Projects/Features/MemeEditor/Sources/ImagePicker.swift b/Projects/Features/MemeEditor/Sources/ImagePicker.swift new file mode 100644 index 0000000..c2c990c --- /dev/null +++ b/Projects/Features/MemeEditor/Sources/ImagePicker.swift @@ -0,0 +1,91 @@ +// +// ImagePicker.swift +// MemeEditor +// +// Created by 장혜령 on 9/25/24. +// + +import SwiftUI +import UIKit +import Photos + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var selectedImage: UIImage? + @Environment(\.presentationMode) var presentationMode + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + var parent: ImagePicker + + init(parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let uiImage = info[.originalImage] as? UIImage { + parent.selectedImage = uiImage + } + parent.presentationMode.wrappedValue.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.presentationMode.wrappedValue.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + checkPhotoLibraryPermission { granted in + if !granted { + print("사진 접근 권한이 허용되어 있지 않음") + showPermissionDeniedAlert(on: picker) + context.coordinator.parent.presentationMode.wrappedValue.dismiss() + } + } + + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + private func checkPhotoLibraryPermission(completion: @escaping (Bool) -> Void) { + let status = PHPhotoLibrary.authorizationStatus() + switch status { + case .authorized: + completion(true) + case .denied, .restricted: + completion(false) + case .notDetermined: + PHPhotoLibrary.requestAuthorization { status in + DispatchQueue.main.async { + completion(status == .authorized) + } + } + default: + completion(false) + } + } + + private func showPermissionDeniedAlert(on viewController: UIViewController) { + let alert = UIAlertController( + title: "사진 접근 권한 필요", + message: "사진을 선택하려면 설정에서 접근 권한을 허용해주세요.", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "취소", style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: "설정으로 이동", style: .default, handler: { _ in + if let appSettings = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(appSettings) + } + })) + + // UIAlertController를 UIImagePickerController 위에 표시 + DispatchQueue.main.async { + viewController.present(alert, animated: true, completion: nil) + } + } +} diff --git a/Projects/Features/MemeEditor/Sources/MemeEditorRouter.swift b/Projects/Features/MemeEditor/Sources/MemeEditorRouter.swift new file mode 100644 index 0000000..e88d466 --- /dev/null +++ b/Projects/Features/MemeEditor/Sources/MemeEditorRouter.swift @@ -0,0 +1,45 @@ +// +// MemeEditorRouter.swift +// MemeEditor +// +// Created by 장혜령 on 9/23/24. +// + +import UIKit + +import PPACUtil +import PPACData +import PPACNetwork +import PPACDomain + +public final class MemeEditorRouter: Router, MemeEditorRouting { + + // MARK: - Properties + public var delegate: (any RouterDelegate)? + public var navigationController: UINavigationController + public var childRouters: [any Router] = [] + + // MARK: - Initializers + public init( + navigationController: UINavigationController + ) { + self.navigationController = navigationController + } + + public func start() { + let networkService = NetworkService() + let keywordRepository = KeywordRepositoryImpl(networkService: networkService) + let memeRepository = MemeRepositoryImpl(networkservice: networkService) + + let memeEditorView = MemeEditorView( + viewModel: MemeEditorViewModel( + router: self, + memeCategorysUseCase: MemeCategorysUseCaseImpl(repository: keywordRepository), + registerMemeUserCase: RegisterMemeUseCaseImpl(repository: memeRepository) + ) + ) + + self.pushView(memeEditorView) + } + +} diff --git a/Projects/Features/MemeEditor/Sources/MemeEditorView.swift b/Projects/Features/MemeEditor/Sources/MemeEditorView.swift new file mode 100644 index 0000000..ed7dc02 --- /dev/null +++ b/Projects/Features/MemeEditor/Sources/MemeEditorView.swift @@ -0,0 +1,182 @@ +// +// MemeEditorView.swift +// MemeEditor +// +// Created by 장혜령 on 9/23/24. +// + +import SwiftUI + +import DesignSystem +import ResourceKit + +struct MemeEditorView: View { + @ObservedObject private var viewModel: MemeEditorViewModel + + public init(viewModel: MemeEditorViewModel) { + self.viewModel = viewModel + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + Rectangle() + .frame(height: 1) + .foregroundStyle(Color.Background.assistive) + .padding(.top, 50) + ScrollView { + VStack { + ImageEditView( + imageUrl: viewModel.state.memeImageUrl, + onImageSelectionCompleted: { selectedImage in + viewModel.state.selectedImage = selectedImage + } + ) + memeTitleInputView + memeSourceInputView + divider + memeCategoriesTitleView + memeCategoriesView + } + .padding(.bottom, 48) + } + + if !viewModel.state.isVisibleKeyboard { + bottomButton + } + } + + if viewModel.state.needLoadingIndicator { + ProgressView() + .scaleEffect(2) + } + } + .onAppear { + viewModel.dispatch(type: .viewWillAppear) + } + .onTapGesture { + endTextEditing() + } + .plainNavigationBar( + backHandler: { + viewModel.dispatch(type: .naviBackButtonTapped) + }, + rightActionHandler: nil, + hasConfigureButton: false, + title: "밈 등록하기" + ) + .popup( + isActive: $viewModel.state.isActivePopup, + image: nil, + text: viewModel.state.contentOfPopup + ) + .basicModal( + isPresented: $viewModel.state.isMemeRegistrationSuccess, + opacity: 0.5, + content: { + FarmemeAlertView( + title: "밈 올리기 성공!", + description: "마이페이지에서 확인할 수 있어요", + dismiss: { + viewModel.dispatch(type: .alertConfirmButtonTapped) + } + ) + } + ) + .onKeyboardChange { isVisible in + viewModel.state.isVisibleKeyboard = isVisible + } + } + + private var memeTitleInputView: some View { + RequiredInputTextFieldView( + title: "밈의 제목", + placeHolder: "예) 럭키비키잖아", + limitedTextCount: 18, + textViewHeight: 46, + content: $viewModel.state.memeTitle + ) + .frame(height: 78) + .padding(.bottom, 40) + } + + private var memeSourceInputView: some View { + RequiredInputTextFieldView( + title: "밈의 출처", + placeHolder: "예) 무한도전, 핀터레스트", + limitedTextCount: 32, + textViewHeight: 82, + content: $viewModel.state.memeSource + ) + .frame(height: 114) + .padding(.bottom, 35) + } + + private var divider: some View { + Rectangle() + .frame(height: 10) + .foregroundStyle(Color.Skeleton.primary) + .padding(.bottom, 35) + } + + private var memeCategoriesTitleView: some View { + VStack(alignment: .leading) { + HStack { + RequiredTitleView(title: "연관있는 키워드를 골라주세요") + .padding(.bottom, 4) + Spacer() + } + HStack { + Text("최대 6개까지 선택 가능해요") + .foregroundStyle(Color.Text.secondary) + .font(Font.Body.Medium.medium) + .padding(.bottom, 24) + Spacer() + } + } + .padding(.horizontal, 20) + } + + private var memeCategoriesView: some View { + ForEach($viewModel.state.memeCategories, id: \.id) { $memeCategory in + let keywordTags = memeCategory.keywords + .map { KeywordTag(id: $0.id, name: $0.name, isSelected: $0.isSelected) } + MemeCategoryView( + category: memeCategory.category, + keywordTags: keywordTags + ) { keyword in + viewModel.dispatch(type: .memeKeywordTapped(keyword: keyword)) + } + } + .id(viewModel.state.memeCategories.count) + } + + private var bottomButton: some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .padding(.horizontal, 20) + .foregroundStyle( + viewModel.state.isMemeFormValid + ? Color.Background.primary + : Color.Background.assistive + ) + Text("등록하기") + .font(Font.Heading.Small.semiBold) + .foregroundStyle( + viewModel.state.isMemeFormValid + ? Color.Text.inverse + : Color.Text.disabled + ) + } + .frame(height: 48) + .foregroundStyle(Color.clear) + .onTapGesture { + guard viewModel.state.isMemeFormValid else { return } + viewModel.dispatch(type: .registerButtonTapped) + } + } +} + +//#Preview { +// MemeEditorView() +//} diff --git a/Projects/Features/MemeEditor/Sources/MemeEditorViewModel.swift b/Projects/Features/MemeEditor/Sources/MemeEditorViewModel.swift new file mode 100644 index 0000000..6a7aeb7 --- /dev/null +++ b/Projects/Features/MemeEditor/Sources/MemeEditorViewModel.swift @@ -0,0 +1,184 @@ +// +// MemeEditorViewModel.swift +// MemeEditor +// +// Created by 장혜령 on 9/23/24. +// + +import SwiftUI + +import PPACUtil +import PPACModels +import PPACDomain +import PPACNetwork + +@MainActor +public protocol MemeEditorRouting: AnyObject { + func popView() +} + +final public class MemeEditorViewModel: ViewModelType, ObservableObject { + + public enum Action { + case viewWillAppear + case naviBackButtonTapped + case memeKeywordTapped(keyword: String) + case registerButtonTapped + case alertConfirmButtonTapped + } + + public struct State { + var memeImageUrl: String = ""// 나중에 수정하기에 쓸 수 있도록 남겨둠 + var selectedImage: UIImage? + var memeTitle: String = "" + var memeSource: String = "" + var memeCategories: [MemeCategory] = [] + var selectedMemeKeywords: [MemeKeyword] = [] + + var isActivePopup: Bool = false + var contentOfPopup: String = "" + var isMemeRegistrationSuccess: Bool = false + var needLoadingIndicator: Bool = false + var isVisibleKeyboard: Bool = false + + var isMemeFormValid: Bool { + return selectedImage != nil + && !memeTitle.isEmpty + && !memeSource.isEmpty + && !selectedMemeKeywords.isEmpty + && selectedMemeKeywords.count <= 6 + } + } + + enum MemeError: Error { + case imageNotAvailable + } + + // MARK: - Properties + @Published public var state: State + weak var router: MemeEditorRouting? + + private let memeCategorysUseCase: MemeCategorysUseCase + private let registerMemeUserCase: RegisterMemeUseCase + + private var allKeywords: [MemeKeyword] { + return self.state.memeCategories + .map { $0.keywords } + .flatMap { $0 } + } + + + public init( + router: MemeEditorRouting, + memeCategorysUseCase: MemeCategorysUseCase, + registerMemeUserCase: RegisterMemeUseCase + ) { + self.router = router + self.state = State() + self.memeCategorysUseCase = memeCategorysUseCase + self.registerMemeUserCase = registerMemeUserCase + } + + public func dispatch(type: Action) { + Task { @MainActor in + switch type { + case .viewWillAppear: + await fetchMemeCategories() + case .naviBackButtonTapped: + router?.popView() + case .memeKeywordTapped(let keyword): + await self.updateSelectedMemeKeyword(keyword) + case .registerButtonTapped: + print("===============================") + print("selectedImage = \(state.selectedImage)") + print("title = \(state.memeTitle)") + print("source = \(state.memeSource)") + print("keywords = \(state.selectedMemeKeywords)") + print("===============================") + await self.registMeme() + case .alertConfirmButtonTapped: + router?.popView() + } + } + } + + @MainActor + private func fetchMemeCategories() async { + do { + let categories = try await memeCategorysUseCase.execute() + self.state.memeCategories = categories + } catch(let error) { + debugPrint("error = \(error)") + } + } + + @MainActor + private func updateSelectedMemeKeyword(_ keyword: String) async { + guard var selectedKeyword = self.allKeywords + .first(where: { $0.name == keyword }) else { return } + + // 선택된 키워드가 있다면 삭제, 없다면 추가 + if let hasSelectedkeywordIndex = self.state.selectedMemeKeywords.firstIndex(where: {$0.id == selectedKeyword.id}) { + self.state.selectedMemeKeywords.remove(at: hasSelectedkeywordIndex) + selectedKeyword.isSelected = false + } else if self.state.selectedMemeKeywords.count >= 6 { + self.showToast(text: "최대 개수를 초과했어요") + return + } else { + self.state.selectedMemeKeywords.append(selectedKeyword) + selectedKeyword.isSelected = true + } + + for (categoryIndex, category) in self.state.memeCategories.enumerated() { + if let keywordIndex = category.keywords.firstIndex(where: { $0.id == selectedKeyword.id }) { + var newKeywords = self.state.memeCategories[categoryIndex].keywords + newKeywords[keywordIndex] = selectedKeyword + self.state.memeCategories[categoryIndex].keywords = newKeywords + } + } + } + + @MainActor + private func registMeme() async { + do { + let imageFormData = try self.getImageFormData() + self.state.needLoadingIndicator = true + try await self.registerMemeUserCase + .execute( + formData: imageFormData, + title: self.state.memeTitle, + source: self.state.memeSource, + keywordIds: self.state.selectedMemeKeywords.map { $0.id } + ) + + self.state.isMemeRegistrationSuccess = true + self.state.needLoadingIndicator = false + } catch MemeError.imageNotAvailable { + self.showToast(text: "지원하지 않는 이미지 형식입니다. 다른 이미지를 사용해주세요") + self.state.needLoadingIndicator = false + } catch(let error) { + print("error = \(error)") + self.showToast(text: "밈 등록에 실패했어요") + self.state.needLoadingIndicator = false + } + } + + private func getImageFormData() throws -> FormData { + guard let imageData = self.state.selectedImage?.jpegData(compressionQuality: 0.8) else { + throw MemeError.imageNotAvailable + } + let formData = FormData( + fieldName: "image", + fileName: "image_\(UUID().uuidString.replacingOccurrences(of: "-", with: "_")).jpg", + mimeType: "image/jpeg", + fileData: imageData + ) + return formData + } + + @MainActor + private func showToast(text: String) { + self.state.contentOfPopup = text + self.state.isActivePopup = true + } +} diff --git a/Projects/Features/MemeEditor/Sources/RequiredInputTextFieldView.swift b/Projects/Features/MemeEditor/Sources/RequiredInputTextFieldView.swift new file mode 100644 index 0000000..14bc7aa --- /dev/null +++ b/Projects/Features/MemeEditor/Sources/RequiredInputTextFieldView.swift @@ -0,0 +1,117 @@ +// +// RequiredInputTextFieldView.swift +// MemeEditor +// +// Created by 장혜령 on 2024/09/20. +// + +import SwiftUI +import ResourceKit + +struct RequiredTitleView: View { + let title: String + + var body: some View { + HStack { + Text(title) + .font(Font.Body.Xlarge.semiBold) + .foregroundStyle(Color.Text.primary) + ResourceKitAsset.Icon.starMarker.swiftUIImage + .resizable() + .frame(width: 12, height: 12) + .padding(.leading, -4) + .padding(.bottom, 10) + } + } +} + +struct RequiredInputTextFieldView: View { + let title: String + let placeHolder: String + let limitedTextCount: Int + let textViewHeight: CGFloat + @Binding var content: String + + var currentTextCount: Int { + return content.count + } + + var body: some View { + VStack(alignment: .leading) { + RequiredTitleView(title: title) + .padding(.bottom, 8) + textFieldWithTextCountView + } + .padding(.horizontal, 20) + } + + var textFieldWithTextCountView: some View { + ZStack(alignment: .bottomTrailing) { + textFieldView + textCountView + } + } + + var textFieldView: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 10) + .foregroundStyle(Color.Background.assistive) + Text(placeHolder) + .foregroundStyle(content.isEmpty ? Color.Text.assistive : Color.clear) + .font(Font.Body.Large.medium) + .padding(.horizontal, 16) + .padding(.top, 12) + TextEditor(text: $content) + .scrollContentBackground(.hidden) + .scrollIndicators(.hidden) + .padding(.horizontal, 12) + .padding(.top, 4) + .font(Font.Body.Large.medium) + .foregroundStyle(Color.Text.primary) + .onChange(of: content) { _ , newValue in + if newValue.count > limitedTextCount { + content = String(newValue.prefix(limitedTextCount)) + } + } + } + } + + var textCountView: some View { + HStack(spacing: 2) { + currentTextCountView + Text("/") + Text("\(limitedTextCount)") + } + .padding(.trailing, 16) + .padding(.bottom, 14) + .foregroundStyle(Color.Text.assistive) + .font(Font.Body.Medium.medium) + } + + var currentTextCountView: some View { + return Text("\(currentTextCount)") + .foregroundStyle(currentTextColor) + } + + var currentTextColor: SwiftUI.Color { + if currentTextCount == 0 { + return Color.Text.assistive + } else if currentTextCount >= limitedTextCount { + return Color.Text.brand + } + return Color.Text.secondary + } + +} + +//#Preview { +// @Previewable @State var content: String = "" +// +// return RequiredInputTextFieldView( +// title: "밈의 제목을 작성해주세요", +// placeHolder: "예) 무한도전, 핀터레스트", +// limitedTextCount: 32, +// textViewHeight: 82, +// content: $content +// ) +//} diff --git a/Projects/Features/MyPage/Sources/Components/SavedMemeListView.swift b/Projects/Features/MyPage/Sources/Components/SavedMemeListView.swift index 4146edb..abc7eec 100644 --- a/Projects/Features/MyPage/Sources/Components/SavedMemeListView.swift +++ b/Projects/Features/MyPage/Sources/Components/SavedMemeListView.swift @@ -19,8 +19,6 @@ struct SavedMemeListView: View { var body: some View { VStack { - ListHeaderView(icon: ResourceKitAsset.Icon.stroke.swiftUIImage, - title: "나의 파밈함") if memeDetailList.count > 0 { memeListView } else { diff --git a/Projects/Features/MyPage/Sources/Components/SegmentedTitleView.swift b/Projects/Features/MyPage/Sources/Components/SegmentedTitleView.swift new file mode 100644 index 0000000..534b87d --- /dev/null +++ b/Projects/Features/MyPage/Sources/Components/SegmentedTitleView.swift @@ -0,0 +1,69 @@ +// +// SegmentedTitleView.swift +// MyPage +// +// Created by 장혜령 on 9/25/24. +// + +import SwiftUI + +import ResourceKit + +struct SegmentedTitleItem: Hashable, Identifiable { + let id = UUID() + let title: String + let isSelected: Bool +} + +struct SegmentedTitleView: View { + + let titleItems: [SegmentedTitleItem] + let titleItemClickHandler: ((String) -> ())? + + init(titleItems: [SegmentedTitleItem], titleItemClickHandler: ((String) -> ())? = nil) { + self.titleItems = titleItems + self.titleItemClickHandler = titleItemClickHandler + } + + var body: some View { + VStack(spacing: 0) { + HStack { + ForEach(titleItems, id: \.self) { item in + SegmentedItemView(item: item) + .onTapGesture { + // 선택되지 않은 title item을 선택했을 때만 업데이트 진행 + guard !item.isSelected else { return } + titleItemClickHandler?(item.title) + } + } + } + .padding(.horizontal, 20) + Rectangle() + .frame(height: 1) + .foregroundStyle(Color.Border.tertiary) + } + .padding(.bottom, 20) + } +} + +struct SegmentedItemView: View { + + let item: SegmentedTitleItem + + var body: some View { + VStack(spacing: 15) { + Text(item.title) + .font(Font.Body.Xlarge.semiBold) + .foregroundStyle(item.isSelected ? Color.Text.primary : Color.Text.tertiary) + Rectangle() + .foregroundStyle(item.isSelected ? Color.Background.primary : Color.clear) + .frame(height: 2) + } + } +} + +#Preview { + let segmentedItems = [SegmentedTitleItem(title: "나의 파밈함", isSelected: true), + SegmentedTitleItem(title: "나의 밈", isSelected: false)] + return SegmentedTitleView(titleItems: segmentedItems) +} diff --git a/Projects/Features/MyPage/Sources/MyPageRouter.swift b/Projects/Features/MyPage/Sources/MyPageRouter.swift index 0fe9bc4..975dd19 100644 --- a/Projects/Features/MyPage/Sources/MyPageRouter.swift +++ b/Projects/Features/MyPage/Sources/MyPageRouter.swift @@ -14,10 +14,12 @@ import PPACDomain import PPACNetwork import PPACData -import MemeDetail import DesignSystem + +import MemeDetail import Setting + public final class MyPageRouter: Router, MyPageRouting { // MARK: - Properties diff --git a/Projects/Features/MyPage/Sources/MyPageView.swift b/Projects/Features/MyPage/Sources/MyPageView.swift index 5dbde94..2397e03 100644 --- a/Projects/Features/MyPage/Sources/MyPageView.swift +++ b/Projects/Features/MyPage/Sources/MyPageView.swift @@ -22,7 +22,6 @@ public struct MyPageView: View { public var body: some View { ZStack(alignment: .top) { -// blurView ScrollView { levelView divider @@ -32,8 +31,14 @@ public struct MyPageView: View { viewModel.dispatch(type: .onTappedRecentMeme(meme: meme)) } ) + SegmentedTitleView( + titleItems: viewModel.state.segmentedTitleItems, + titleItemClickHandler: { title in + viewModel.dispatch(type: .onTappedSegmentedTitleItem(title: title)) + } + ) SavedMemeListView( - memeDetailList: $viewModel.state.savedMemeList, + memeDetailList: $viewModel.state.currentMyMemeList, memeClickHandler: { meme in viewModel.dispatch(type: .onTappedSavedMeme(meme: meme)) }, @@ -60,15 +65,6 @@ public struct MyPageView: View { text: "이미지를 클립보드에 복사했어요" ) } - - var blurView: some View { - Rectangle() - .frame(height: 0) - .foregroundStyle(Color.clear) - .background(.ultraThinMaterial) - .blur(radius: 0) - .zIndex(1) - } var levelView: some View { VStack { @@ -120,6 +116,9 @@ public struct MyPageView: View { .foregroundStyle(Color.Skeleton.primary) .padding(.bottom, 20) } + + + } diff --git a/Projects/Features/MyPage/Sources/MyPageViewModel.swift b/Projects/Features/MyPage/Sources/MyPageViewModel.swift index 1ef5b98..21d7a48 100644 --- a/Projects/Features/MyPage/Sources/MyPageViewModel.swift +++ b/Projects/Features/MyPage/Sources/MyPageViewModel.swift @@ -33,6 +33,7 @@ final public class MyPageViewModel: ViewModelType, ObservableObject { case onTappedRecentMeme(meme: MemeDetail?) case onTappedSavedMeme(meme: MemeDetail?) case onTappedCopyButton(meme: MemeDetail?) + case onTappedSegmentedTitleItem(title: String) case onAppearLastMeme } @@ -41,8 +42,27 @@ final public class MyPageViewModel: ViewModelType, ObservableObject { var lastSeenMemeList: [MemeDetail] var savedMemeList: [MemeDetail] var savedMemePagination: MemeListWithPagination.Pagination - var isRefreshCompleted: Bool = true - var isActiveCopyPopup: Bool = false + var isRefreshCompleted: Bool + var isActiveCopyPopup: Bool + + var currentMyMemeList: [MemeDetail] + var segmentedTitleItems: [SegmentedTitleItem] = [] + + init( + userDetail: UserDetail, + lastSeenMemeList: [MemeDetail], + savedMemeList: [MemeDetail], + savedMemePagination: MemeListWithPagination.Pagination + ) { + self.userDetail = userDetail + self.lastSeenMemeList = lastSeenMemeList + self.savedMemeList = savedMemeList + self.savedMemePagination = savedMemePagination + self.isRefreshCompleted = true + self.isActiveCopyPopup = false + self.currentMyMemeList = lastSeenMemeList // TODO: 나중에 나의 밈으로 바뀌어야함 + self.segmentedTitleItems = initSegmentedTitleItems() + } var memeLevel: MemeLevelType { return MemeLevelType(rawValue: userDetail.level) ?? .level1 @@ -64,6 +84,11 @@ final public class MyPageViewModel: ViewModelType, ObservableObject { var hasNextPageOfSavedMeme: Bool { return savedMemePagination.currentPage < savedMemePagination.totalPages } + + private func initSegmentedTitleItems() -> [SegmentedTitleItem] { + return [SegmentedTitleItem(title: "나의 밈", isSelected: true), + SegmentedTitleItem(title: "나의 파밈함", isSelected: false)] + } } // MARK: - Properties @@ -119,6 +144,9 @@ final public class MyPageViewModel: ViewModelType, ObservableObject { self.logMyPage(event: .meme, type: .savedMeme) case .onTappedCopyButton(let meme): await self.copyMemeImage(with: meme) + case .onTappedSegmentedTitleItem(let title): + self.updateSegmentedTitleItems(selectedTitle: title) + self.updateCurrentMyMemeList(selectedTitle: title) case .onAppearLastMeme: await self.fetchNextPageSavedMeme() } @@ -190,6 +218,29 @@ final public class MyPageViewModel: ViewModelType, ObservableObject { } } + @MainActor + private func updateSegmentedTitleItems(selectedTitle: String) { + guard let selectedItem = self.state.segmentedTitleItems + .first(where: { $0.title == selectedTitle }) else { return } + + let newTitleItems = self.state.segmentedTitleItems + .map { + SegmentedTitleItem( + title: $0.title, + isSelected: $0.title == selectedItem.title + ) + } + self.state.segmentedTitleItems = newTitleItems + } + + private func updateCurrentMyMemeList(selectedTitle: String) { + if selectedTitle == "나의 밈" { + self.state.currentMyMemeList = self.state.lastSeenMemeList + } else { + self.state.currentMyMemeList = self.state.savedMemeList + } + } + func logMyPage( interaction: PPACAnalytics.UserInteraction = .click, event: PPACAnalytics.UserEvent, diff --git a/Projects/Features/Recommend/Project.swift b/Projects/Features/Recommend/Project.swift index be8e9ff..252a8d6 100644 --- a/Projects/Features/Recommend/Project.swift +++ b/Projects/Features/Recommend/Project.swift @@ -30,7 +30,8 @@ let project = Project( .Core.PPACData, .Core.PPACUtil, .Core.PPACAnalytics, - .Feature.MemeDetail + .Feature.MemeDetail, + .Feature.MemeEditor ] ) ] diff --git a/Projects/Features/Recommend/Sources/Presentation/RecommendRouter.swift b/Projects/Features/Recommend/Sources/Presentation/RecommendRouter.swift index b6dcd17..b3b1760 100644 --- a/Projects/Features/Recommend/Sources/Presentation/RecommendRouter.swift +++ b/Projects/Features/Recommend/Sources/Presentation/RecommendRouter.swift @@ -15,7 +15,9 @@ import PPACDomain import PPACData import PPACNetwork import DesignSystem + import MemeDetail +import MemeEditor public final class RecommendRouter: Router, RecommendRouting { @@ -85,4 +87,10 @@ public final class RecommendRouter: Router, RecommendRouting { self.childRouters.append(router) router.start() } + + public func showMemeEditorView() { + let router = MemeEditorRouter(navigationController: self.navigationController) + self.childRouters.append(router) + router.start() + } } diff --git a/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift b/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift index a60e1f1..396b85b 100644 --- a/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift +++ b/Projects/Features/Recommend/Sources/Presentation/RecommendView.swift @@ -61,6 +61,9 @@ public struct RecommendView: View { recommendMemeSize: $viewModel.state.recommendMemeSize ) + // TODO: 종윤쓰 여기 수정 부탁함다 + registerButton + ZStack { VStack(spacing: 0) { @@ -178,6 +181,25 @@ public struct RecommendView: View { ) } + // FIXME: 등록하기 버튼 수정 + private var registerButton: some View { + Button( + action: { + viewModel.dispatch(type: .memeRegisterButtonTapped) + }, + label: { + ZStack { + RoundedRectangle(cornerRadius: 10) + .foregroundStyle(Color.Background.primary) + Text("나도 밈 올리기") + .foregroundStyle(Color.Text.inverse) + } + .frame(width: 130, height: 36) + .padding() + } + ) + } + private func reactionButtonTap() { viewModel.dispatch( type: .likeButtonTapped(meme: currentMeme) diff --git a/Projects/Features/Recommend/Sources/Presentation/RecommendViewModel.swift b/Projects/Features/Recommend/Sources/Presentation/RecommendViewModel.swift index 0f6db3e..2e80670 100644 --- a/Projects/Features/Recommend/Sources/Presentation/RecommendViewModel.swift +++ b/Projects/Features/Recommend/Sources/Presentation/RecommendViewModel.swift @@ -17,6 +17,7 @@ import PPACAnalytics public protocol RecommendRouting: AnyObject { func showShareView(items: [Any]) func showMemeDetailView(meme: MemeDetail) + func showMemeEditorView() } public final class RecommendViewModel: ViewModelType, ObservableObject { @@ -28,6 +29,7 @@ public final class RecommendViewModel: ViewModelType, ObservableObject { case copyButtonTapped(meme: MemeDetail?) case shareButtonTapped(meme: MemeDetail?) case farmemeButtonTapped(meme: MemeDetail?) + case memeRegisterButtonTapped } public struct State { @@ -36,6 +38,7 @@ public final class RecommendViewModel: ViewModelType, ObservableObject { var userLevel: Int var memeRecommendWatchCount: Int var isSuccessFetch: Bool + var isSuccessMemeRegister: Bool = false } weak var router: RecommendRouting? @@ -93,6 +96,8 @@ public final class RecommendViewModel: ViewModelType, ObservableObject { await showShareSheet(meme: meme) case .farmemeButtonTapped(let meme): await saveMeme(meme: meme) + case .memeRegisterButtonTapped: + router?.showMemeEditorView() } } } diff --git a/Projects/Features/Search/Sources/View/SearchView.swift b/Projects/Features/Search/Sources/View/SearchView.swift index 689551b..b82d9df 100644 --- a/Projects/Features/Search/Sources/View/SearchView.swift +++ b/Projects/Features/Search/Sources/View/SearchView.swift @@ -47,9 +47,13 @@ public struct SearchView: View { isPresented: $viewModel.state.isPresenting, opacity: 0.5, content: { - SearchPreparingAlert { - viewModel.dispatch(type: .dismissSearchBarAlert) - } + FarmemeAlertView( + title: "조금만 기다려주세요!", + description: "검색은 준비 중이에요.", + dismiss: { + viewModel.dispatch(type: .dismissSearchBarAlert) + } + ) } ) @@ -112,7 +116,7 @@ public struct SearchView: View { ForEach(viewModel.state.memeCategories, id: \.self) { memeCategory in MemeCategoryView( category: memeCategory.category, - keywords: memeCategory.keywords + keywordTags: memeCategory.keywords.map { KeywordTag(id: $0.id, name: $0.name) } ) { keyword in viewModel.dispatch(type: .recommendKeywordTapped(keyword: keyword)) viewModel.logSearch(event: .keyword, keyword: keyword, category: memeCategory.category) diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album.png b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album.png new file mode 100644 index 0000000..553007a Binary files /dev/null and b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album.png differ diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album@2x.png b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album@2x.png new file mode 100644 index 0000000..8cbde54 Binary files /dev/null and b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album@2x.png differ diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album@3x.png b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album@3x.png new file mode 100644 index 0000000..817282d Binary files /dev/null and b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Album@3x.png differ diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Contents.json b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Contents.json new file mode 100644 index 0000000..61d232a --- /dev/null +++ b/Projects/ResourceKit/Resources/Icon.xcassets/Album.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Album.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Album@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Album@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Contents.json b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Contents.json new file mode 100644 index 0000000..cbe9ee5 --- /dev/null +++ b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Star_Marker.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Star_Marker@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Star_Marker@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker.png b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker.png new file mode 100644 index 0000000..a1ab533 Binary files /dev/null and b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker.png differ diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker@2x.png b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker@2x.png new file mode 100644 index 0000000..e93427f Binary files /dev/null and b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker@2x.png differ diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker@3x.png b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker@3x.png new file mode 100644 index 0000000..40f90c1 Binary files /dev/null and b/Projects/ResourceKit/Resources/Icon.xcassets/Star_Marker.imageset/Star_Marker@3x.png differ diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 207e360..617030d 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -9,6 +9,15 @@ "version" : "1.2024011602.0" } }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", + "version" : "5.9.1" + } + }, { "identity" : "app-check", "kind" : "remoteSourceControl", @@ -207,6 +216,15 @@ "version" : "1.17.4" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" + } + }, { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 3404361..47a03cf 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -10,7 +10,7 @@ import PackageDescription "Lottie": .framework, "Kingfisher": .framework, "PopupView": .framework, - "SkeletonUI": .framework, + "SkeletonUI": .framework ] ) #endif diff --git a/Tuist/ProjectDescriptionHelpers/TargetDependency+.swift b/Tuist/ProjectDescriptionHelpers/TargetDependency+.swift index 04ce146..a75b457 100644 --- a/Tuist/ProjectDescriptionHelpers/TargetDependency+.swift +++ b/Tuist/ProjectDescriptionHelpers/TargetDependency+.swift @@ -17,6 +17,7 @@ extension TargetDependency { public static let MyPage = project(moduleName: "MyPage") public static let MemeDetail = project(moduleName: "MemeDetail") public static let Setting = project(moduleName: "Setting") + public static let MemeEditor = project(moduleName: "MemeEditor") } public struct Core {