diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index f50b562e..acb4536a 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -146,8 +146,13 @@ final class TodoService { let snapshot = try await firestoreQuery.getDocuments() let todos = snapshot.documents.compactMap { makeResponse(from: $0) } + let todoNumber = searchedTodoNumber(from: trimmedKeyword) let filtered = todos.filter { todo in - todo.title.localizedCaseInsensitiveContains(trimmedKeyword) + if let todoNumber, todo.number == todoNumber { + return true + } + + return todo.title.localizedCaseInsensitiveContains(trimmedKeyword) || todo.content.localizedCaseInsensitiveContains(trimmedKeyword) || todo.tags.contains { $0.localizedCaseInsensitiveContains(trimmedKeyword) } } @@ -483,6 +488,19 @@ private extension TodoService { ) } + func searchedTodoNumber(from keyword: String) -> Int? { + guard keyword.hasPrefix("#") else { + return nil + } + + let numberText = String(keyword.dropFirst()) + guard !numberText.isEmpty, numberText.allSatisfy(\.isNumber) else { + return nil + } + + return Int(numberText) + } + enum TodoFieldKey: String { case id case isPinned diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 0a8ab7a5..6bfc606b 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -107,6 +107,7 @@ final class SearchViewModel: Store { cancelDebounce() state.webPages = [] state.todos = [] + state.isLoading = false } else { state.isLoading = true scheduleDebouncedQuery(query) @@ -136,12 +137,16 @@ final class SearchViewModel: Store { do { send(.setLoading(true)) defer { send(.setLoading(false)) } + let searchesTodoOnly = searchesTodoOnly(query) async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) - async let webPages = fetchWebPagesUseCase.execute(query) + async let webPageItems = fetchWebPageItems( + query: query, + searchesTodoOnly: searchesTodoOnly + ) let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } - let webPageItems = try await webPages.map { WebPageItem(from: $0) } + let resolvedWebPageItems = try await webPageItems send(.fetchTodos(todoItems)) - send(.fetchWebPage(webPageItems)) + send(.fetchWebPage(resolvedWebPageItems)) } catch { send(.setAlert(true)) } @@ -177,6 +182,22 @@ private extension SearchViewModel { searchDebounceTask = nil } + func searchesTodoOnly(_ query: String) -> Bool { + query.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#") + } + + func fetchWebPageItems( + query: String, + searchesTodoOnly: Bool + ) async throws -> [WebPageItem] { + if searchesTodoOnly { + return [] + } + + let webPages = try await fetchWebPagesUseCase.execute(query) + return webPages.map { WebPageItem(from: $0) } + } + func saveRecentQueries(_ queries: OrderedSet) { updateRecentSearchQueriesUseCase.execute(Array(queries)) } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 5e533343..4a057543 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -1495,13 +1495,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search" + "value" : "Search (e.g. #123)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "검색" + "value" : "검색 (예: #123)" } } } @@ -2950,13 +2950,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search %1$@" + "value" : "Search %1$@ (e.g. #123)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ 검색" + "value" : "%1$@ 검색 (예: #123)" } } } @@ -3367,4 +3367,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index dec3802b..2c63c3c5 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -61,9 +61,7 @@ struct SearchView: View { @ViewBuilder private var searchableContent: some View { Group { - if viewModel.state.isLoading { - LoadingView() - } else if viewModel.state.searchQuery.isEmpty { + if viewModel.state.searchQuery.isEmpty { if viewModel.state.recentQueries.isEmpty { searchInstruction } else { @@ -71,6 +69,8 @@ struct SearchView: View { recentQueries } } + } else if viewModel.state.isLoading { + LoadingView() } else if viewModel.state.webPages.isEmpty && viewModel.state.todos.isEmpty { emptySearchResult } else { diff --git a/Firebase/firestore.index.json b/Firebase/firestore.index.json index 9162f6c2..914b6b4d 100644 --- a/Firebase/firestore.index.json +++ b/Firebase/firestore.index.json @@ -32,6 +32,24 @@ } ] }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "deletedAt", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "todoLists", "queryScope": "COLLECTION",