diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift b/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift index 7181a773..a0719979 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift @@ -18,7 +18,7 @@ struct SearchFeature { var loading = LoadingFeature.State() var isSearching = false var searchQuery = "" - var todos: [TodoListItem] = [] + var todos: [SearchTodoItem] = [] var recentQueries = OrderedSet() var showAllTodos = false let contentsLimit = 5 @@ -31,7 +31,7 @@ struct SearchFeature { loading.isLoading } - var visibleTodos: [TodoListItem] { + var visibleTodos: [SearchTodoItem] { if showAllTodos { return todos } @@ -60,7 +60,7 @@ struct SearchFeature { case loading(LoadingFeature.Action) enum StoreAction: Equatable { - case fetchTodos([TodoListItem]) + case fetchTodos([SearchTodoItem]) case applySearchQuery(String) case setAlert(Bool) } @@ -208,7 +208,7 @@ private extension SearchFeature { .run { [fetchTodosUseCase] send in do { let todos = try await fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) - let todoItems = todos.items.compactMap { TodoListItem(from: $0) } + let todoItems = todos.items.map(SearchTodoItem.init(todo:)) await send(.store(.fetchTodos(todoItems))) if isLoading { await send(.loading(.end(target: .default, mode: .immediate))) diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchRowItem.swift b/Application/Presentation/HomeTab/Sources/Home/Search/SearchRowItem.swift new file mode 100644 index 00000000..10448f9a --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/Search/SearchRowItem.swift @@ -0,0 +1,27 @@ +// +// SearchRowItem.swift +// HomeTab +// +// Created by 최윤진 on 9/11/26. +// + +import Domain +import Foundation + +struct SearchTodoItem: Identifiable, Hashable { + public let id: String + public let number: Int + public let title: String + public let category: TodoCategory + public let createdAt: Date + public let isPinned: Bool + + public init(todo: Todo) { + self.id = todo.id + self.number = todo.number + self.title = todo.title + self.category = todo.category + self.createdAt = todo.createdAt + self.isPinned = todo.isPinned + } +} diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift index 0e324006..c39eaa06 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift @@ -14,194 +14,354 @@ struct SearchView: View { @State private var router = NavigationRouter() @State var store: StoreOf - init(store: StoreOf) { - self.store = store - } - var body: some View { NavigationStack(path: $router.path) { - searchableContent - .navigationDestination(for: Path.self) { path in - switch path { - case .todo(let todoId): - TodoDetailView(store: Store( - initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: true) - ) { - TodoDetailFeature() - }) - } + ScrollView { + LazyVStack(alignment: .leading, spacing: 24, pinnedViews: [.sectionHeaders]) { + Section { + if !store.searchQuery.isEmpty { + SearchResults( + store: store, + onSelectTodo: { router.push(.todo($0)) } + ) + } + RecentSearchQuries(store: store) + instruction + } header: { tipCard } } - .onAppear { store.send(.onAppear) } - .onChange(of: store.isSearching) { _, isSearching in - if !isSearching { - dismiss() - } + .padding(.horizontal) + } + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .background(Color.appBackground.ignoresSafeArea()) + .prominentAlert(store, state: \.alert, action: \.alert) + .navigationDestination(for: Path.self) { path in + switch path { + case .todo(let todoId): + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: true) + ) { + TodoDetailFeature() + }) } - .prominentAlert(store, state: \.alert, action: \.alert) + } } } - @ViewBuilder - private var searchableContent: some View { - Group { - if store.searchQuery.isEmpty { - if store.recentQueries.isEmpty { - searchInstruction - } else { - ScrollView { - recentQueries - } - } - } else if store.isHashOnlyQuery { - hashGuide - } else if store.isLoading { - LoadingView() - } else if store.todos.isEmpty { - emptySearchResult - } else { - ScrollView { - searchResults - .frame(maxWidth: .infinity, alignment: .leading) - } + private var topBar: some View { + VStack(alignment: .leading) { + Button { + store.send(.binding(.set(\.isSearching, false))) + dismiss() + } label: { + Image(systemName: "chevron.left") + .foregroundStyle(Color.textSecondary) + .font(.title) + .padding(6) } + .adaptiveButtonStyle(shape: .circle, color: .border) + SearchField(store: store) } - .searchable( - text: $store.searchQuery, - isPresented: $store.isSearching, - placement: .navigationBarDrawer(displayMode: .always), - prompt: Text(String(localized: "search_prompt", bundle: PresentationResources.bundle)) - ) - .onSubmit(of: .search) { - store.send(.addRecentQuery(store.searchQuery)) - } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom, 8) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) } - private var searchInstruction: some View { - VStack { - Spacer() - Text(String(localized: "search_instruction", bundle: PresentationResources.bundle)) - .foregroundStyle(Color.gray) + private var tipCard: some View { + HStack(alignment: .top) { + Image(systemName: "info.circle.fill") + .font(.title) + VStack(alignment: .leading) { + Text(String( + localized: "search_todo_number_tip_title", + bundle: PresentationResources.bundle + )) + .bold() + Text(String( + localized: "search_todo_number_tip_message", + bundle: PresentationResources.bundle + )) + .foregroundStyle(Color.textSecondary) + .font(.caption) + } Spacer() } - .frame(maxWidth: .infinity) - } - - private var emptySearchResult: some View { - VStack { - Spacer() - Text(String(localized: "search_empty", bundle: PresentationResources.bundle)) - .foregroundStyle(Color.gray) - Spacer() + .foregroundStyle(Color.accent) + .padding() + .background { + Rectangle() + .fill(Color.appBackground) + RoundedRectangle(cornerRadius: 12) + .fill(Color.primaryContainer) } - .frame(maxWidth: .infinity) } - private var hashGuide: some View { - VStack(spacing: 8) { + private var instruction: some View { + HStack { Spacer() - Text(String(localized: "search_hash_guide_title", bundle: PresentationResources.bundle)) - .font(.headline) - .foregroundStyle(Color(.label)) - Text(String(localized: "search_hash_guide_message", bundle: PresentationResources.bundle)) - .font(.subheadline) - .foregroundStyle(Color.gray) + Text(String( + localized: "search_scope_instruction", + bundle: PresentationResources.bundle + )) + .foregroundStyle(Color.textSecondary) .multilineTextAlignment(.center) Spacer() } - .padding(.horizontal, 24) - .frame(maxWidth: .infinity) } +} - private var searchResults: some View { - VStack(alignment: .leading, spacing: 16) { - if !store.todos.isEmpty { - todoResults +private struct SearchField: View { + let store: StoreOf + @FocusState private var isFocused: Bool + + private var searchQuery: Binding { + Binding( + get: { store.searchQuery }, + set: { query in + guard query != store.searchQuery else { return } + store.send(.binding(.set(\.searchQuery, query))) } + ) + } + + var body: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(Color.textSecondary) + TextField( + "", + text: searchQuery, + prompt: Text(String( + localized: "search_prompt", + bundle: PresentationResources.bundle) + ) + .foregroundColor(Color.secondary), + ) + .focused($isFocused) + .onSubmit { + store.send(.addRecentQuery(store.searchQuery)) + } + if !store.searchQuery.isEmpty { + Button { + store.send(.binding(.set(\.searchQuery, ""))) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.textSecondary) + } + .buttonStyle(.plain) + } + } + .font(.title3) + .padding() + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + .onAppear { + isFocused = true } - .padding(.vertical, 8) } +} + +private struct SearchResults: View { + let store: StoreOf + let onSelectTodo: (String) -> Void - private var todoResults: some View { + var body: some View { let todos = store.visibleTodos - return VStack(alignment: .leading, spacing: 12) { - Text("Todos", bundle: PresentationResources.bundle) - .font(.headline) - .foregroundStyle(Color(.label)) - Divider() - LazyVStack(spacing: 0) { - ForEach(todos, id: \.id) { todo in - todoResultRow(todo) - } + VStack(spacing: 8) { + HStack(spacing: 12) { + Text(String( + localized: "search_results_title", + bundle: PresentationResources.bundle + )) + .font(.title3) + .bold() + Spacer() + Text(String.localizedStringWithFormat( + String( + localized: "search_result_count_format", + bundle: PresentationResources.bundle + ), + Int64(store.todos.count) + )) + .font(.callout.bold()) + .foregroundStyle(Color.accent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.primaryContainer, in: .capsule) } - .padding(.top, -12) - if store.shouldShowMoreTodos { - Button(String(localized: "search_show_more", bundle: PresentationResources.bundle)) { - store.send(.setShowAllTodos(true)) + if store.isHashOnlyQuery { + VStack(spacing: 8) { + Text(String( + localized: "search_hash_guide_title", + bundle: PresentationResources.bundle) + ) + .font(.headline) + Text(String( + localized: "search_hash_guide_message", + bundle: PresentationResources.bundle) + ) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else if store.isLoading && todos.isEmpty { + ProgressView() + .tint(Color.accent) + } else if todos.isEmpty { + Text(String( + localized: "todo_list_search_empty", + bundle: PresentationResources.bundle) + ) + } else { + LazyVStack(spacing: 0) { + ForEach(Array(zip(todos.indices, todos)), id: \.1.id) { index, item in + Button { + onSelectTodo(item.id) + } label: { + SearchResultRow(item: item) + .todoDetailPreview(todoId: item.id) + } + .buttonStyle(.plain) + if index < todos.count - 1 { Divider() } + } + } + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + if store.shouldShowMoreTodos { + Button { + store.send(.setShowAllTodos(true)) + } label: { + Text(String( + localized: "search_show_more", + bundle: PresentationResources.bundle) + ) + .foregroundStyle(Color.accent) + .font(.callout.bold()) + } + .adaptiveButtonStyle(color: .primaryContainer) } - .font(.subheadline) - .foregroundStyle(Color.gray) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 4) } } - .padding(.horizontal, 16) + .frame(maxWidth: .infinity) } +} - private func todoResultRow(_ item: TodoListItem) -> some View { - Button { - router.push(Path.todo(item.id)) - } label: { - VStack(spacing: 0) { - TodoItemRow(item) - Divider() +private struct SearchResultRow: View { + @ScaledMetric(relativeTo: .title2) private var iconSize = CGFloat(48) + @Environment(\.colorScheme) private var colorScheme + let item: SearchTodoItem + private var isDarkMode: Bool { colorScheme == .dark } + + var body: some View { + let category = TodoCategoryItem(from: item.category) + + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 14) + .fill(category.color.opacity(isDarkMode ? 1 : 0.2)) + .frame(width: iconSize, height: iconSize) + .overlay { + Image(systemName: category.symbolName) + .font(.title3.bold()) + .foregroundStyle(isDarkMode ? .white : category.color) + } + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + .lineLimit(1) + HStack(spacing: 4) { + Text("#\(item.number)") + .foregroundStyle(Color.accent) + Text("·") + Text(item.createdAt.formatted(.dateTime.month().day().weekday(.abbreviated))) + } + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) } + Spacer(minLength: 8) + Image(systemName: item.isPinned ? "star.fill" : "star") + .font(.title3) + .foregroundStyle(item.isPinned ? Color.orange : .textTertiary) + Image(systemName: "chevron.right") + .font(.callout.bold()) + .foregroundStyle(Color.textSecondary) } - .todoDetailPreview(todoId: item.id) + .padding() } +} + +private struct RecentSearchQuries: View { + let store: StoreOf - private var recentQueries: some View { - VStack(alignment: .leading, spacing: 12) { + var body: some View { + VStack(spacing: 8) { HStack { Text(String(localized: "search_recent", bundle: PresentationResources.bundle)) - .font(.headline) - .foregroundStyle(Color(.label)) + .font(.title3) + .bold() Spacer() - Button(String(localized: "search_clear_all", bundle: PresentationResources.bundle)) { + Button { store.send(.clearRecentQueries) + } label: { + Image(systemName: "trash") + .font(.callout) + .foregroundStyle( + store.recentQueries.isEmpty ? + Color.textSecondary : .onPrimaryContainer + ) } - .font(.subheadline) - .foregroundStyle(Color.gray) + .disabled(store.recentQueries.isEmpty) + .padding(.trailing) } - - ForEach(store.recentQueries, id: \.self) { query in - HStack { - Image(systemName: "clock.arrow.circlepath") - .foregroundStyle(Color.gray) - Text(query) - .foregroundStyle(Color.primary) - Spacer() - Button { - store.send(.removeRecentQuery(query)) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Color.gray) + LazyVStack(spacing: 0) { + ForEach(Array(zip( + store.recentQueries.indices, + store.recentQueries)), id: \.1 + ) { idx, query in + VStack(spacing: 0) { + HStack { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(Color.textSecondary) + Text(query) + .lineLimit(1) + Spacer() + } + .contentShape(.rect) + .onTapGesture { + store.send(.binding(.set(\.searchQuery, query))) + } + Button { + store.send(.removeRecentQuery(query)) + } label: { + Image(systemName: "xmark") + .foregroundStyle(Color.textSecondary) + } + } + .padding() + if idx < store.recentQueries.count - 1 { Divider() } } - .buttonStyle(.plain) - } - .padding(.vertical, 4) - .contentShape(Rectangle()) - .onTapGesture { - store.send(.binding(.set(\.searchQuery, query))) - store.send(.binding(.set(\.isSearching, true))) } } + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } } - .padding(.horizontal, 16) - .padding(.vertical, 8) + .frame(maxWidth: .infinity) } +} - private enum Path: Hashable { - case todo(String) - } +private enum Path: Hashable { + case todo(String) } diff --git a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift index 46a4bfc0..adc09bd3 100644 --- a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift +++ b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift @@ -18,7 +18,7 @@ struct SearchStoreTestAdapter { var searchQuery: String { store.state.searchQuery } var isSearching: Bool { store.state.isSearching } var isLoading: Bool { store.state.isLoading } - var todos: [TodoListItem] { store.state.todos } + var todos: [SearchTodoItem] { store.state.todos } var recentQueries: [String] { Array(store.state.recentQueries) } var showAllTodos: Bool { store.state.showAllTodos } var isHashOnlyQuery: Bool { store.state.isHashOnlyQuery } @@ -26,7 +26,7 @@ struct SearchStoreTestAdapter { init( recentQueries: [String] = [], - initialTodos: [TodoListItem] = [], + initialTodos: [SearchTodoItem] = [], isSearching: Bool = false, isLoading: Bool = false, fetchTodosUseCase: FetchTodosUseCase = SearchFetchTodosUseCaseSpy(), @@ -124,7 +124,7 @@ struct SearchStoreTestAdapter { await store.receive(.store(.applySearchQuery(query))) } - func receiveSearchResults(todos: [TodoListItem]) async { + func receiveSearchResults(todos: [SearchTodoItem]) async { let wasLoading = store.state.isLoading await store.receive(.store(.fetchTodos(todos))) { $0.todos = todos diff --git a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift index 9a930e73..a131c3e7 100644 --- a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift @@ -95,18 +95,18 @@ struct SearchFeatureTests { await adapter.setSearchQuery(" swift ") await clock.advance(by: .milliseconds(400)) await adapter.receiveAppliedSearchQuery("swift") - await adapter.receiveSearchResults(todos: [TodoListItem(from: todo)!]) + await adapter.receiveSearchResults(todos: [SearchTodoItem(todo: todo)]) #expect(adapter.searchQuery == " swift ") #expect(!adapter.showAllTodos) #expect(todoSpy.queries.map(\.keyword) == ["swift"]) - #expect(adapter.todos == [TodoListItem(from: todo)]) + #expect(adapter.todos == [SearchTodoItem(todo: todo)]) #expect(!adapter.isLoading) } @Test("빈 검색어는 검색 결과를 비우고 로딩을 종료한다") func 빈_검색어는_검색_결과를_비우고_로딩을_종료한다() async { - let todo = TodoListItem(from: makeSearchTodo(id: "todo-1"))! + let todo = SearchTodoItem(todo: makeSearchTodo(id: "todo-1")) let adapter = SearchStoreTestAdapter( initialTodos: [todo], isLoading: true @@ -120,7 +120,7 @@ struct SearchFeatureTests { @Test("# 단독 검색어는 안내 상태로 전환하고 조회를 시작하지 않는다") func 해시_단독_검색어는_안내_상태로_전환하고_조회를_시작하지_않는다() async { - let todo = TodoListItem(from: makeSearchTodo(id: "todo-1"))! + let todo = SearchTodoItem(todo: makeSearchTodo(id: "todo-1")) let todoSpy = SearchFetchTodosUseCaseSpy() let adapter = SearchStoreTestAdapter( initialTodos: [todo], @@ -143,10 +143,10 @@ struct SearchFeatureTests { let adapter = SearchStoreTestAdapter(fetchTodosUseCase: todoSpy) await adapter.applySearchQuery(" #123 ") - await adapter.receiveSearchResults(todos: [TodoListItem(from: todo)!]) + await adapter.receiveSearchResults(todos: [SearchTodoItem(todo: todo)]) #expect(todoSpy.queries.map(\.keyword) == ["#123"]) - #expect(adapter.todos == [TodoListItem(from: todo)]) + #expect(adapter.todos == [SearchTodoItem(todo: todo)]) } @Test("검색 실패 시 공통 에러 알림을 표시하고 로딩을 종료한다") diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index c44a87fd..29323199 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -1590,6 +1590,57 @@ } } }, + "search_result_count_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld results" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld개" + } + } + } + }, + "search_results_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search Results" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색 결과" + } + } + } + }, + "search_scope_instruction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Only Todos are searched.\nDocuments and other content aren't included." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo만 검색됩니다.\n문서나 다른 콘텐츠는 검색되지 않아요." + } + } + } + }, "search_prompt" : { "extractionState" : "manual", "localizations" : { @@ -1641,6 +1692,57 @@ } } }, + "search_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색" + } + } + } + }, + "search_todo_number_tip_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter something like #123 to find that Todo." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#123처럼 입력하면 해당 Todo를 찾을 수 있어요." + } + } + } + }, + "search_todo_number_tip_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find Todo by Number" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 번호로 바로 찾기" + } + } + } + }, "settings_account" : { "extractionState" : "manual", "localizations" : {