diff --git a/Projects/Core/PPACData/Sources/DTO/MemeResponseDTO.swift b/Projects/Core/PPACData/Sources/DTO/MemeResponseDTO.swift index 2aa52ba..3f05ef6 100644 --- a/Projects/Core/PPACData/Sources/DTO/MemeResponseDTO.swift +++ b/Projects/Core/PPACData/Sources/DTO/MemeResponseDTO.swift @@ -45,7 +45,7 @@ extension MemeWithPaginationResponseDTO { struct MemeResponseDTO: Decodable { let _id: String let title: String - let keywords: [KeywordResponseDTO] + let keywords: [KeywordResponseDTO]? let image: String let reaction: Int let source: String @@ -53,14 +53,14 @@ struct MemeResponseDTO: Decodable { let isDeleted: Bool? let createdAt: String? let updatedAt: String - let isSaved: Bool - let isReaction: Bool + let isSaved: Bool? + let isReaction: Bool? let watch: Int? public init( _id: String, title: String, - keywords: [KeywordResponseDTO], + keywords: [KeywordResponseDTO]?, image: String, reaction: Int, source: String, @@ -68,8 +68,8 @@ struct MemeResponseDTO: Decodable { isDeleted: Bool?, createdAt: String?, updatedAt: String, - isSaved: Bool, - isReaction: Bool, + isSaved: Bool?, + isReaction: Bool?, watch: Int? ) { @@ -108,13 +108,13 @@ extension MemeResponseDTO { return MemeDetail( id: self._id, title: self.title, - keywords: self.keywords.map { $0.name }, + keywords: self.keywords?.compactMap { $0.name } ?? [], imageUrlString: self.image, source: self.source, isTodayMeme: self.isTodayMeme, reaction: self.reaction, - isFarmemed: self.isSaved, - isReaction: self.isReaction + isFarmemed: self.isSaved ?? false, + isReaction: self.isReaction ?? false ) } } diff --git a/Projects/Core/PPACData/Sources/Endpoint/MemeEndpoint.swift b/Projects/Core/PPACData/Sources/Endpoint/MemeEndpoint.swift index cd19117..e41731e 100644 --- a/Projects/Core/PPACData/Sources/Endpoint/MemeEndpoint.swift +++ b/Projects/Core/PPACData/Sources/Endpoint/MemeEndpoint.swift @@ -13,6 +13,7 @@ import PPACUtil public enum MemeEndpoint: Requestable { case recommendMeme(size: Int) case getSearchKeywordMemeList(page: Int, size: Int, keyword: String) + case getSearchByTextMemeList(page: Int, size: Int, text: String) case meme(memeId: String) case bookmark(memeId: String) case deleteBookmark(memeId: String) @@ -26,6 +27,8 @@ public enum MemeEndpoint: Requestable { return .get case .getSearchKeywordMemeList: return .get + case .getSearchByTextMemeList: + return .get case .meme: return .get case .bookmark: @@ -51,6 +54,8 @@ public enum MemeEndpoint: Requestable { return "/meme/recommend-memes" case .getSearchKeywordMemeList(_,_,let keyword): return "/meme/search/\(keyword)" + case .getSearchByTextMemeList(_,_,let text): + return "/meme/search" case .meme(let memeId): return "/meme/\(memeId)" case .bookmark(let memeId): @@ -77,6 +82,13 @@ public enum MemeEndpoint: Requestable { "keyword" : "\(keyword)" ] return .query(parameters) + case .getSearchByTextMemeList(let page, let size, let text): + let parameters: [String: String] = [ + "q": "\(text)", + "page" : "\(page)", + "size" : "\(size)", + ] + return .query(parameters) case .bookmark: return nil case .deleteBookmark: diff --git a/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift b/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift index 4fe58ae..95950d1 100644 --- a/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift +++ b/Projects/Core/PPACData/Sources/Repository/MemeRepositoryImpl.swift @@ -55,6 +55,23 @@ public class MemeRepositoryImpl: MemeRepository { } } + public func getSearchByTextMemeList( + page: Int, + size: Int, + text: String + ) async throws -> MemeListWithPagination { + let endpoint = MemeEndpoint.getSearchByTextMemeList(page: page, size: size, text: text) + let result = await networkservice.request(endpoint, dataType: BaseDTO.self) + + switch result { + case .success(let data): + guard let memeWithPaginationResponseDTO = data.data else { throw NetworkError.dataDecodingError } + return memeWithPaginationResponseDTO.toModel() + case .failure(let error): + throw error + } + } + public func getMemeDetail(memeId: String) async throws -> MemeDetail { let endpoint = MemeEndpoint.meme(memeId: memeId) let result = await networkservice.request(endpoint, dataType: BaseDTO.self) diff --git a/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift b/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift index ed25652..e823532 100644 --- a/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift +++ b/Projects/Core/PPACDomain/Sources/Repository/MemeRepository.swift @@ -14,6 +14,7 @@ public protocol MemeRepository { func getRecommendMemes(size: Int) async throws -> [MemeDetail] func getSearchKeywordMemeList(page: Int, size: Int, keyword: String) async throws -> MemeListWithPagination + func getSearchByTextMemeList(page: Int, size: Int, text: String) async throws -> MemeListWithPagination func getMemeDetail(memeId: String) async throws -> MemeDetail func bookmarkMeme(memeId: String) async throws func deleteBookmarkMeme(memeId: String) async throws diff --git a/Projects/Core/PPACDomain/Sources/UseCase/Meme/SearchKeywordUseCase.swift b/Projects/Core/PPACDomain/Sources/UseCase/Meme/SearchKeywordUseCase.swift index 58041b3..9d81b6d 100644 --- a/Projects/Core/PPACDomain/Sources/UseCase/Meme/SearchKeywordUseCase.swift +++ b/Projects/Core/PPACDomain/Sources/UseCase/Meme/SearchKeywordUseCase.swift @@ -24,3 +24,19 @@ public class SearchKeywordUseCaseImpl: SearchKeywordUseCase { try await repository.getSearchKeywordMemeList(page: page, size: size, keyword: keyword) } } + +public protocol SearchByTextUseCase { + func execute(page: Int, size: Int, text: String) async throws -> MemeListWithPagination +} + +public class SearchByTextUseCaseImpl: SearchByTextUseCase { + private let repository: MemeRepository + + public init(repository: MemeRepository) { + self.repository = repository + } + + public func execute(page: Int, size: Int, text: String) async throws -> MemeListWithPagination { + try await repository.getSearchByTextMemeList(page: page, size: size, text: text) + } +} diff --git a/Projects/Core/PPACModels/Sources/Meme/MemeListWithPagination.swift b/Projects/Core/PPACModels/Sources/Meme/MemeListWithPagination.swift index 4430eed..e60b24a 100644 --- a/Projects/Core/PPACModels/Sources/Meme/MemeListWithPagination.swift +++ b/Projects/Core/PPACModels/Sources/Meme/MemeListWithPagination.swift @@ -27,7 +27,7 @@ public struct MemeListWithPagination { /// page 당 밈 개수 public let perPageOfMemes: Int /// 현재 page - public let currentPage: Int + public var currentPage: Int public init( totalPages: Int, diff --git a/Projects/Features/Search/Sources/View/Result/SearchResultRouter.swift b/Projects/Features/Search/Sources/View/Result/SearchResultRouter.swift index 521d027..17b10c5 100644 --- a/Projects/Features/Search/Sources/View/Result/SearchResultRouter.swift +++ b/Projects/Features/Search/Sources/View/Result/SearchResultRouter.swift @@ -24,13 +24,15 @@ public final class SearchResultRouter: Router, SearchResultRouting { public var childRouters: [any Router] = [] let keyword: String - + let text: String + // MARK: - Initializers - public init(_ navigationController: UINavigationController, keyword: String) { + public init(_ navigationController: UINavigationController, keyword: String, text: String) { navigationController.isNavigationBarHidden = true self.navigationController = navigationController self.keyword = keyword + self.text = text } // MARK: - Methods @@ -41,8 +43,10 @@ public final class SearchResultRouter: Router, SearchResultRouting { self.pushView( SearchResultView(viewModel: SearchResultViewModel( keyword: keyword, - router: self, + text: text, + router: self, searchKeywordUseCase: SearchKeywordUseCaseImpl(repository: repository), + searchByTextUseCase: SearchByTextUseCaseImpl(repository: repository), copyImageUseCase: CopyImageUseCaseImpl(), watchMemeUseCase: WatchMemeUseCaseImpl(repository: repository) )) diff --git a/Projects/Features/Search/Sources/View/Result/SearchResultView.swift b/Projects/Features/Search/Sources/View/Result/SearchResultView.swift index 9b80b8e..4202306 100644 --- a/Projects/Features/Search/Sources/View/Result/SearchResultView.swift +++ b/Projects/Features/Search/Sources/View/Result/SearchResultView.swift @@ -15,18 +15,34 @@ import PopupView public struct SearchResultView: View { @ObservedObject var viewModel: SearchResultViewModel + @State var text: String public init(viewModel: SearchResultViewModel) { self.viewModel = viewModel + self.text = viewModel.state.text } public var body: some View { VStack(spacing: 0) { + HStack(spacing: 12) { + Button(action: { + viewModel.dispatch(type: .naviBackButtonTapped) + }) { + ResourceKitAsset.Icon.back.swiftUIImage + } + + SearchBar(text: $text) + .onSubmit { + viewModel.dispatch(type: .search(text: text)) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 6) + Rectangle() .fill(Color.Background.assistive) .frame(maxWidth: .infinity) .frame(height: 1) - .padding(.top, 51) ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -38,12 +54,6 @@ public struct SearchResultView: View { } } } - .plainNavigationBar( - backHandler: { viewModel.dispatch(type: .naviBackButtonTapped) }, - rightActionHandler: nil, - hasConfigureButton: false, - title: viewModel.state.keyword - ) .onAppear { viewModel.dispatch(type: .viewWillAppear) } @@ -56,7 +66,7 @@ public struct SearchResultView: View { private var memeListView: some View { VStack(alignment: .leading, spacing: 0) { - Text("\(viewModel.state.memeList.count)개의 밈을 찾았어요") + Text("\(viewModel.state.memePagination.totalMemes)개의 밈을 찾았어요") .font(Font.Body.Medium.medium) .foregroundColor(Color.Text.primary) .padding(.all, 20) diff --git a/Projects/Features/Search/Sources/View/Result/SearchResultViewModel.swift b/Projects/Features/Search/Sources/View/Result/SearchResultViewModel.swift index d859e89..8c098e5 100644 --- a/Projects/Features/Search/Sources/View/Result/SearchResultViewModel.swift +++ b/Projects/Features/Search/Sources/View/Result/SearchResultViewModel.swift @@ -27,6 +27,7 @@ public final class SearchResultViewModel: ViewModelType, ObservableObject { public enum Action { case viewWillAppear + case search(text: String) case memeDetailTapped(meme: MemeDetail) case memeCopyTapped(meme: MemeDetail) case naviBackButtonTapped @@ -35,6 +36,7 @@ public final class SearchResultViewModel: ViewModelType, ObservableObject { public struct State { var keyword: String + var text: String var memeList: [MemeDetail] var memePagination: MemeListWithPagination.Pagination var isActiveCopyPopup: Bool = false @@ -47,6 +49,7 @@ public final class SearchResultViewModel: ViewModelType, ObservableObject { @Published public var state: State private let searchKeywordUseCase: SearchKeywordUseCase + private let searchByTextUseCase: SearchByTextUseCase private let copyImageUseCase: CopyImageUseCase private let watchMemeUseCase: WatchMemeUseCase @@ -54,14 +57,22 @@ public final class SearchResultViewModel: ViewModelType, ObservableObject { public init( keyword: String, + text: String, router: SearchResultRouting?, searchKeywordUseCase: SearchKeywordUseCase, + searchByTextUseCase: SearchByTextUseCase, copyImageUseCase: CopyImageUseCase, watchMemeUseCase: WatchMemeUseCase ) { self.router = router - self.state = State(keyword: keyword, memeList: [], memePagination: .default) + self.state = State( + keyword: keyword, + text: text, + memeList: [], + memePagination: .default + ) self.searchKeywordUseCase = searchKeywordUseCase + self.searchByTextUseCase = searchByTextUseCase self.copyImageUseCase = copyImageUseCase self.watchMemeUseCase = watchMemeUseCase } @@ -74,6 +85,8 @@ public final class SearchResultViewModel: ViewModelType, ObservableObject { switch type { case .viewWillAppear: await fetchData() + case .search(text: let text): + await fetchData(with: text) case .memeDetailTapped(let meme): router?.showMemeDetail(memeDetail: meme) logSearch(event: .meme, keyword: state.keyword) @@ -90,21 +103,45 @@ public final class SearchResultViewModel: ViewModelType, ObservableObject { } @MainActor - private func fetchData() async { + private func fetchData(with newText: String = "") async { do { - guard state.memePagination.currentPage < state.memePagination.totalPages else { return } + if newText.isEmpty == false { + state.text = newText + state.keyword = "" + state.memeList = [] + state.memePagination.currentPage = -1 + } + + guard state.memePagination.currentPage < state.memePagination.totalPages + else { + return + } state.isLoading = true - let result = try await searchKeywordUseCase - .execute( - page: state.memePagination.currentPage + 1, - size: state.memePagination.perPageOfMemes, - keyword: state.keyword - ) + if state.text.isEmpty == false { + let result = try await searchByTextUseCase + .execute( + page: state.memePagination.currentPage + 1, + size: state.memePagination.perPageOfMemes, + text: state.text + ) + + state.memeList += result.memeList + state.memePagination = result.pagination + state.isLoading = false - state.memeList += result.memeList - state.memePagination = result.pagination - state.isLoading = false + } else if state.keyword.isEmpty == false { + let result = try await searchKeywordUseCase + .execute( + page: state.memePagination.currentPage + 1, + size: state.memePagination.perPageOfMemes, + keyword: state.keyword + ) + + state.memeList += result.memeList + state.memePagination = result.pagination + state.isLoading = false + } self.logSearch( interaction: .scroll, diff --git a/Projects/Features/Search/Sources/View/FakeSearchBar.swift b/Projects/Features/Search/Sources/View/SearchBar.swift similarity index 57% rename from Projects/Features/Search/Sources/View/FakeSearchBar.swift rename to Projects/Features/Search/Sources/View/SearchBar.swift index cb88305..7b8c164 100644 --- a/Projects/Features/Search/Sources/View/FakeSearchBar.swift +++ b/Projects/Features/Search/Sources/View/SearchBar.swift @@ -1,5 +1,5 @@ // -// FakeSearchBar.swift +// SearchBar.swift // DesignSystem // // Created by 리나 on 2024/06/29. @@ -8,32 +8,31 @@ import SwiftUI import ResourceKit -struct FakeSearchBar: View { - let placeHolder: String - - init(placeHolder: String) { - self.placeHolder = placeHolder - } +struct SearchBar: View { + @Binding var text: String var body: some View { fakeTextField .frame(maxWidth: .infinity) - .frame(height: 44) + .frame(height: 38) .background(Color.Background.assistive) .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal, 20) - .padding(.vertical, 16) } private var fakeTextField: some View { HStack(spacing: 12) { ResourceKitAsset.Icon.search.swiftUIImage - Text(placeHolder) - .font(Font.Body.Large.medium) - .foregroundColor(Color.Text.tertiary) + TextField("찾고 싶은 밈 있어?", text: $text) Spacer() + + Button(action: { + text = "" + }, label: { + ResourceKitAsset.Icon.deleteTextField.swiftUIImage + }) + .opacity(text.isEmpty ? 0 : 1) } .padding(.horizontal, 16) } diff --git a/Projects/Features/Search/Sources/View/SearchRouter.swift b/Projects/Features/Search/Sources/View/SearchRouter.swift index 3bd2e55..330607e 100644 --- a/Projects/Features/Search/Sources/View/SearchRouter.swift +++ b/Projects/Features/Search/Sources/View/SearchRouter.swift @@ -47,7 +47,13 @@ public final class SearchRouter: Router, SearchRouting { } public func showSearchResult(keyword: String) { - let router = SearchResultRouter(self.navigationController, keyword: keyword) + let router = SearchResultRouter(self.navigationController, keyword: keyword, text: "") + self.childRouters.append(router) + router.start() + } + + public func showSearchResultByText(text: String) { + let router = SearchResultRouter(self.navigationController, keyword: "", text: text) self.childRouters.append(router) router.start() } diff --git a/Projects/Features/Search/Sources/View/SearchView.swift b/Projects/Features/Search/Sources/View/SearchView.swift index b82d9df..d691f4f 100644 --- a/Projects/Features/Search/Sources/View/SearchView.swift +++ b/Projects/Features/Search/Sources/View/SearchView.swift @@ -19,6 +19,7 @@ import SkeletonUI public struct SearchView: View { @Environment(\.screenSize) var screenSize @ObservedObject var viewModel: SearchViewModel + @State var text: String = "" public init(viewModel: SearchViewModel) { self.viewModel = viewModel @@ -27,8 +28,19 @@ public struct SearchView: View { public var body: some View { ZStack { VStack(spacing: 0) { - fakeSearchBar + SearchBar(text: $text) + .padding(.horizontal, 20) + .padding(.vertical, 6) + .onSubmit { + viewModel.dispatch(type: .search(text: text)) + text = "" + } + Rectangle() + .fill(Color.Background.assistive) + .frame(maxWidth: .infinity) + .frame(height: 1) + ScrollView { VStack(spacing: 0) { currentHotKeywords @@ -43,19 +55,6 @@ public struct SearchView: View { .onAppear { viewModel.dispatch(type: .viewWillAppear) } - .basicModal( - isPresented: $viewModel.state.isPresenting, - opacity: 0.5, - content: { - FarmemeAlertView( - title: "조금만 기다려주세요!", - description: "검색은 준비 중이에요.", - dismiss: { - viewModel.dispatch(type: .dismissSearchBarAlert) - } - ) - } - ) if viewModel.state.isLoading { skeletonView @@ -64,15 +63,6 @@ public struct SearchView: View { .animation(.easeInOut, value: viewModel.state.isLoading) } - private var fakeSearchBar: some View { - Button { - viewModel.dispatch(type: .searchBarTapped) - } label: { - FakeSearchBar(placeHolder: "🚧 검색은 오픈 준비 중!") - } - .buttonStyle(PlainButtonStyle()) - } - private var currentHotKeywords: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { diff --git a/Projects/Features/Search/Sources/View/SearchViewModel.swift b/Projects/Features/Search/Sources/View/SearchViewModel.swift index 0f3bad1..3796209 100644 --- a/Projects/Features/Search/Sources/View/SearchViewModel.swift +++ b/Projects/Features/Search/Sources/View/SearchViewModel.swift @@ -20,14 +20,14 @@ import MemeDetail @MainActor public protocol SearchRouting: AnyObject { func showSearchResult(keyword: String) + func showSearchResultByText(text: String) } public final class SearchViewModel: ViewModelType, ObservableObject { public enum Action { case viewWillAppear - case searchBarTapped - case dismissSearchBarAlert + case search(text: String) case hotKeywordTapped(keyword: String) case recommendKeywordTapped(keyword: String) } @@ -35,7 +35,6 @@ public final class SearchViewModel: ViewModelType, ObservableObject { public struct State { var hotKeywords: [HotKeyword] var memeCategories: [MemeCategory] - var isPresenting: Bool = false var isLoading: Bool = true } @@ -68,11 +67,8 @@ public final class SearchViewModel: ViewModelType, ObservableObject { switch type { case .viewWillAppear: await fetchData() - case .searchBarTapped: - state.isPresenting = true - logSearch(event: .searchBar) - case .dismissSearchBarAlert: - state.isPresenting = false + case .search(let text): + router?.showSearchResultByText(text: text) case .hotKeywordTapped(let keyword): router?.showSearchResult(keyword: keyword) logSearch(event: .hotKeyword, keyword: keyword) diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Delete_textField.imageset/Contents.json b/Projects/ResourceKit/Resources/Icon.xcassets/Delete_textField.imageset/Contents.json new file mode 100644 index 0000000..4c75627 --- /dev/null +++ b/Projects/ResourceKit/Resources/Icon.xcassets/Delete_textField.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Delete.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/ResourceKit/Resources/Icon.xcassets/Delete_textField.imageset/Delete.pdf b/Projects/ResourceKit/Resources/Icon.xcassets/Delete_textField.imageset/Delete.pdf new file mode 100644 index 0000000..f3e64d1 Binary files /dev/null and b/Projects/ResourceKit/Resources/Icon.xcassets/Delete_textField.imageset/Delete.pdf differ