From 90b0f72751b3dd1f785245c8a183c241ceff39dc Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 20 Feb 2026 14:58:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=20=EC=B5=9C=EB=8C=80=2020=EA=B0=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EB=B7=B0=EC=97=90=20=EB=9D=84?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/SearchViewModel.swift | 41 +++++++++++- DevLog/Resource/Localizable.xcstrings | 6 ++ DevLog/UI/Search/SearchView.swift | 67 ++++++++++++++++--- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 3b4c38a..9e688aa 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -15,6 +15,7 @@ final class SearchViewModel: Store { var searchQuery: String = "" var selectedWebPage: WebPageItem? var webPages: OrderedSet = [] + var recentQueries: OrderedSet = [] var filteredWebPages: [WebPageItem] { webPages.filter { $0.title.localizedCaseInsensitiveContains(searchQuery) || @@ -30,6 +31,9 @@ final class SearchViewModel: Store { case onAppear case fetchWebPage([WebPageItem]? = nil) case selectWebPage(WebPageItem) + case addRecentQuery(String) + case removeRecentQuery(String) + case clearRecentQueries case setAlert(Bool) case setLoading(Bool) case setSearching(Bool) @@ -42,9 +46,21 @@ final class SearchViewModel: Store { @Published private(set) var state: State = .init() private let fetchWebPagesUseCase: FetchWebPagesUseCase + private let userDefaults: UserDefaults - init(fetchWebPagesUseCase: FetchWebPagesUseCase) { + private enum DefaultsKey { + static let recentQueries = "Search.recentQueries" + } + + private let maxRecentQueries = 20 + + init( + fetchWebPagesUseCase: FetchWebPagesUseCase, + userDefaults: UserDefaults = .standard + ) { self.fetchWebPagesUseCase = fetchWebPagesUseCase + self.userDefaults = userDefaults + self.state.recentQueries = Self.loadRecentQueries(userDefaults: userDefaults) } func reduce(with action: Action) -> [SideEffect] { @@ -63,6 +79,21 @@ final class SearchViewModel: Store { state.webPages = OrderedSet(items) case .selectWebPage(let item): state.selectedWebPage = item + case .addRecentQuery(let query): + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { break } + state.recentQueries.remove(trimmed) + state.recentQueries.insert(trimmed, at: 0) + if maxRecentQueries < state.recentQueries.count { + state.recentQueries = OrderedSet(state.recentQueries.prefix(maxRecentQueries)) + } + saveRecentQueries(Array(state.recentQueries)) + case .removeRecentQuery(let query): + state.recentQueries.remove(query) + saveRecentQueries(Array(state.recentQueries)) + case .clearRecentQueries: + state.recentQueries = [] + saveRecentQueries([]) case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .setLoading(let isLoading): @@ -103,4 +134,12 @@ private extension SearchViewModel { state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." state.showAlert = isPresented } + + static func loadRecentQueries(userDefaults: UserDefaults) -> OrderedSet { + OrderedSet(userDefaults.stringArray(forKey: DefaultsKey.recentQueries) ?? []) + } + + func saveRecentQueries(_ queries: [String]) { + userDefaults.set(queries, forKey: DefaultsKey.recentQueries) + } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 9d49120..584f182 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -352,6 +352,9 @@ }, "작성된 내용이 없습니다." : { + }, + "전체 삭제" : { + }, "정렬 옵션" : { @@ -370,6 +373,9 @@ }, "지난주" : { + }, + "최근 검색" : { + }, "최근에 중요 표시를 한 Todo가 여기 표시됩니다." : { diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index d729f1f..add2dd9 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -65,6 +65,8 @@ struct SearchView: View { LazyVStack(alignment: .leading, spacing: 0) { if viewModel.state.isLoading { LoadingView() + } else if !viewModel.state.recentQueries.isEmpty { + recentQueries } else if viewModel.state.searchQuery.isEmpty { searchInstruction } else if viewModel.state.filteredWebPages.isEmpty { @@ -76,20 +78,25 @@ struct SearchView: View { .frame(maxWidth: .infinity, alignment: .leading) } - if #available(iOS 17.0, *) { - scrollContent.searchable( - text: searchQueryBinding, - isPresented: searchingBinding, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "검색" - ) - } else { - scrollContent - .searchable( + Group { + if #available(iOS 17.0, *) { + scrollContent.searchable( text: searchQueryBinding, + isPresented: searchingBinding, placement: .navigationBarDrawer(displayMode: .always), prompt: "검색" ) + } else { + scrollContent + .searchable( + text: searchQueryBinding, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "검색" + ) + } + } + .onSubmit(of: .search) { + viewModel.send(.addRecentQuery(viewModel.state.searchQuery)) } } @@ -153,6 +160,46 @@ struct SearchView: View { .padding(.vertical, 8) } + private var recentQueries: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("최근 검색") + .font(.headline) + .foregroundStyle(Color(.label)) + Spacer() + Button("전체 삭제") { + viewModel.send(.clearRecentQueries) + } + .font(.subheadline) + .foregroundStyle(Color.gray) + } + + ForEach(viewModel.state.recentQueries, id: \.self) { query in + HStack { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(Color.gray) + Text(query) + .foregroundStyle(Color.primary) + Spacer() + Button { + viewModel.send(.removeRecentQuery(query)) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.gray) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.send(.setSearchQuery(query)) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + private enum Path: Hashable { case webView(URL) } From a43c2d43b35700f4835c414ddadb326dfb7c5257 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 20 Feb 2026 15:25:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4=EB=A5=BC=20=ED=83=AD=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=EA=B2=80=EC=83=89=EC=96=B4=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=B4=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EB=9D=84?= =?UTF-8?q?=EC=9B=8C=EC=A3=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Search/SearchView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index add2dd9..1357101 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -65,10 +65,12 @@ struct SearchView: View { LazyVStack(alignment: .leading, spacing: 0) { if viewModel.state.isLoading { LoadingView() - } else if !viewModel.state.recentQueries.isEmpty { - recentQueries } else if viewModel.state.searchQuery.isEmpty { - searchInstruction + if viewModel.state.recentQueries.isEmpty { + searchInstruction + } else { + recentQueries + } } else if viewModel.state.filteredWebPages.isEmpty { emptySearchResult } else { @@ -193,6 +195,7 @@ struct SearchView: View { .contentShape(Rectangle()) .onTapGesture { viewModel.send(.setSearchQuery(query)) + viewModel.send(.setSearching(true)) } } }