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..1357101 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -66,7 +66,11 @@ struct SearchView: View { if viewModel.state.isLoading { LoadingView() } else if viewModel.state.searchQuery.isEmpty { - searchInstruction + if viewModel.state.recentQueries.isEmpty { + searchInstruction + } else { + recentQueries + } } else if viewModel.state.filteredWebPages.isEmpty { emptySearchResult } else { @@ -76,20 +80,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 +162,47 @@ 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)) + viewModel.send(.setSearching(true)) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + private enum Path: Hashable { case webView(URL) }