diff --git a/DevLog/Presentation/Enum/TodoScope.swift b/DevLog/Presentation/Enum/TodoScope.swift deleted file mode 100644 index 1cb37856..00000000 --- a/DevLog/Presentation/Enum/TodoScope.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// TodoScope.swift -// DevLog -// -// Created by opfic on 6/12/25. -// - -import Foundation - -enum TodoScope: String, CaseIterable { - case title, content - - var localizedName: String { - let key: String.LocalizationValue - switch self { - case .title: - key = "TodoScope.title" - case .content: - key = "TodoScope.content" - } - return String(localized: key) - } -} diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 0ca43bf6..92402814 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -11,12 +11,14 @@ final class TodoListViewModel: Store { struct State { var todos: [TodoListItem] = [] var searchText: String = "" + var searchResults: [TodoListItem] = [] let kind: TodoKind var showEditor: Bool = false var showAlert: Bool = false var alertTitle: String = "" var alertMessage: String = "" - var scope: TodoScope = .title + var isSearching: Bool = false + var showAllSearchResults: Bool = false var query: TodoQuery var isLoading: Bool = false var showToast: Bool = false @@ -37,6 +39,8 @@ final class TodoListViewModel: Store { case togglePinnedOnly case setCompletionFilter(TodoQuery.CompletionFilter) case resetFilters + case setIsSearching(Bool) + case setShowAllSearchResults(Bool) case tapToggleCompleted(TodoListItem) case tapTogglePinned(TodoListItem) case undoDelete @@ -45,12 +49,13 @@ final class TodoListViewModel: Store { case confirmDelete case onAppear case loadNextPage - case setScope(TodoScope) case setSearchText(String) case setToast(isPresented: Bool) case upsertTodo(Todo) // Run + case setSearchQuery(String) + case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) case setLoading(Bool) @@ -62,6 +67,7 @@ final class TodoListViewModel: Store { enum SideEffect { case fetch case loadNextPage + case search(String) case upsert(Todo) case delete(String) case toggleCompleted(TodoListItem) @@ -69,6 +75,8 @@ final class TodoListViewModel: Store { } @Published private(set) var state: State + private let searchDebounceDelay: Double = 0.4 + private var searchDebounceTask: Task? private let fetchTodosUseCase: FetchTodosUseCase private let fetchTodoByIDUseCase: FetchTodoByIDUseCase private let upsertTodoUseCase: UpsertTodoUseCase @@ -91,6 +99,8 @@ final class TodoListViewModel: Store { ) } + let searchResultsLimit = 5 + var appliedFilterCount: Int { var count = 0 if state.query.sortTarget != .createdAt { count += 1 } @@ -106,14 +116,15 @@ final class TodoListViewModel: Store { switch action { case .refresh, .setAlert, .setShowEditor, .swipeTodo, .setSortTarget, .setSortOrder, - .togglePinnedOnly, .setCompletionFilter, .resetFilters, .tapToggleCompleted, - .tapTogglePinned, .undoDelete: + .togglePinnedOnly, .setCompletionFilter, .resetFilters, .setIsSearching, + .setShowAllSearchResults, .tapToggleCompleted, .tapTogglePinned, .undoDelete: effects = reduceByUser(action, state: &state) - case .confirmDelete, .onAppear, .loadNextPage, .setScope, .setSearchText, .setToast, .upsertTodo: + case .confirmDelete, .onAppear, .loadNextPage, .setSearchText, .setToast, .upsertTodo: effects = reduceByView(action, state: &state) - case .didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore: + case .setSearchQuery, .fetchSearchResults, + .didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -150,6 +161,18 @@ final class TodoListViewModel: Store { send(.setAlert(true)) } } + case .search(let keyword): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + let query = TodoQuery(kind: state.kind, keyword: keyword) + let page = try await fetchTodosUseCase.execute(query, cursor: nil) + send(.fetchSearchResults(page.items.map { TodoListItem(from: $0) })) + } catch { + send(.setAlert(true)) + } + } case .upsert(let item): Task { do { @@ -246,6 +269,16 @@ private extension TodoListViewModel { state.query = TodoQuery(kind: state.kind) state.nextCursor = nil return [.fetch] + case .setIsSearching(let value): + state.isSearching = value + if !value { + cancelDebounce() + state.searchText = "" + state.searchResults = [] + state.showAllSearchResults = false + } + case .setShowAllSearchResults(let value): + state.showAllSearchResults = value case .tapToggleCompleted(let todo): return [.toggleCompleted(todo)] case .tapTogglePinned(let todo): @@ -275,10 +308,18 @@ private extension TodoListViewModel { case .loadNextPage: guard state.hasMore, !state.isLoading, state.pendingTask == nil else { return [] } return [.loadNextPage] - case .setScope(let scope): - state.scope = scope case .setSearchText(let text): state.searchText = text + state.showAllSearchResults = false + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + cancelDebounce() + state.searchResults = [] + state.isLoading = false + } else { + state.isLoading = true + scheduleDebouncedQuery(text) + } case .setToast(let isPresented): setToast(&state, isPresented: isPresented) case .upsertTodo(let todo): @@ -291,6 +332,15 @@ private extension TodoListViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { + case .setSearchQuery(let query): + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + state.searchResults = [] + } else { + return [.search(trimmed)] + } + case .fetchSearchResults(let items): + state.searchResults = items case .didToggleCompleted(let todo): if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { state.todos[index] = todo @@ -341,6 +391,23 @@ private extension TodoListViewModel { state.toastMessage = "실행 취소" state.showToast = isPresented } + + func scheduleDebouncedQuery(_ query: String) { + searchDebounceTask?.cancel() + searchDebounceTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(for: .seconds(searchDebounceDelay)) + if Task.isCancelled { return } + await MainActor.run { + self.send(.setSearchQuery(query)) + } + } + } + + func cancelDebounce() { + searchDebounceTask?.cancel() + searchDebounceTask = nil + } } extension TodoQuery.SortTarget { diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index fdae106f..58437a2e 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -233,26 +233,6 @@ }, "Todos" : { - }, - "TodoScope.content" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "내용" - } - } - } - }, - "TodoScope.title" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "제목" - } - } - } }, "Web Page" : { @@ -271,6 +251,9 @@ }, "검색어를 입력해 저장한 앱 컨텐츠를 찾아보세요." : { + }, + "검색어를 입력해주세요." : { + }, "계정 삭제" : { diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 4dff961e..f93f7338 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -14,6 +14,79 @@ struct TodoListView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { + Group { + if viewModel.state.isSearching { + todoSearchContent + } else { + todoListContent + } + } + .navigationDestination(for: Path.self) { path in + switch path { + case .detail(let todoID): + TodoDetailView(viewModel: TodoDetailViewModel( + fetchUseCase: container.resolve(FetchTodoByIDUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoID: todoID + )) + } + } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + )) { + Button("확인", role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .toast( + isPresented: Binding( + get: { viewModel.state.showToast }, + set: { viewModel.send(.setToast(isPresented: $0)) } + ), + duration: 5, + action: { viewModel.send(.undoDelete) }, + onDismiss: { viewModel.send(.confirmDelete) } + ) { + Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") + } + .navigationTitle(viewModel.state.kind.localizedName) + .navigationBarTitleDisplayMode(.large) + .fullScreenCover(isPresented: Binding( + get: { viewModel.state.showEditor }, + set: { viewModel.send(.setShowEditor($0)) } + )) { + TodoEditorView( + viewModel: TodoEditorViewModel(kind: viewModel.state.kind), + onSubmit: { viewModel.send(.upsertTodo($0)) } + ) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.send(.setShowEditor(true)) + } label: { + Image(systemName: "plus") + } + } + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.send(.setIsSearching(true)) + } label: { + Image(systemName: "magnifyingglass") + } + } + } + .toolbarBackground(.visible, for: .navigationBar) + .task { viewModel.send(.onAppear) } + } + + private var todoListContent: some View { ZStack { List { Section { @@ -72,80 +145,92 @@ struct TodoListView: View { .refreshable { viewModel.send(.refresh) } - .navigationDestination(for: Path.self) { path in - switch path { - case .detail(let todoID): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchUseCase: container.resolve(FetchTodoByIDUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoID: todoID - )) - } - } if viewModel.state.isLoading { LoadingView() } } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } - )) { - Button("확인", role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) - } - .toast( - isPresented: Binding( - get: { viewModel.state.showToast }, - set: { viewModel.send(.setToast(isPresented: $0)) } - ), - duration: 5, - action: { viewModel.send(.undoDelete) }, - onDismiss: { viewModel.send(.confirmDelete) } - ) { - Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") - } - .navigationTitle(viewModel.state.kind.localizedName) - .navigationBarTitleDisplayMode(.large) - .fullScreenCover(isPresented: Binding( - get: { viewModel.state.showEditor }, - set: { viewModel.send(.setShowEditor($0)) } - )) { - TodoEditorView( - viewModel: TodoEditorViewModel(kind: viewModel.state.kind), - onSubmit: { viewModel.send(.upsertTodo($0)) } - ) - } - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - viewModel.send(.setShowEditor(true)) - } label: { - Image(systemName: "plus") + } + + @ViewBuilder + private var todoSearchContent: some View { + let searchTextBinding = Binding( + get: { viewModel.state.searchText }, + set: { viewModel.send(.setSearchText($0)) } + ) + let isSearchingBinding = Binding( + get: { viewModel.state.isSearching }, + set: { viewModel.send(.setIsSearching($0)) } + ) + + let searchResults = viewModel.state.searchResults + let limit = viewModel.searchResultsLimit + let displayedTodos = viewModel.state.showAllSearchResults + ? searchResults + : Array(searchResults.prefix(limit)) + + let content = ScrollView { + LazyVStack(spacing: 0) { + if viewModel.state.searchText.isEmpty { + Text("검색어를 입력해주세요.") + .foregroundStyle(Color.gray) + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else if viewModel.state.isLoading { + LoadingView() + .padding(.top, 40) + } else if searchResults.isEmpty { + Text("검색 결과가 없습니다.") + .foregroundStyle(Color.gray) + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else { + ForEach(displayedTodos) { todo in + Button { + router.push(Path.detail(todo.id)) + } label: { + VStack(spacing: 0) { + TodoItemRow(todo) + Divider() + } + } + } + .padding(.horizontal, 16) + + if !viewModel.state.showAllSearchResults, limit < searchResults.count { + Button("더보기") { + viewModel.send(.setShowAllSearchResults(true)) + } + .font(.subheadline) + .foregroundStyle(Color.gray) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) + } } } } - .toolbarBackground(.visible, for: .navigationBar) - .searchable( - text: Binding( - get: { viewModel.state.searchText }, - set: { viewModel.send(.setSearchText($0)) } - ), - placement: .navigationBarDrawer(displayMode: .always), - prompt: "\(viewModel.state.kind.localizedName) 검색" - ) - .searchScopes(Binding( - get: { viewModel.state.scope }, - set: { viewModel.send(.setScope($0)) } - )) { - ForEach(TodoScope.allCases, id: \.self) { scope in - Text(scope.localizedName).tag(scope) + + Group { + if #available(iOS 17.0, *) { + content.searchable( + text: searchTextBinding, + isPresented: isSearchingBinding, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "\(viewModel.state.kind.localizedName) 검색" + ) + } else { + content.searchable( + text: searchTextBinding, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "\(viewModel.state.kind.localizedName) 검색" + ) + } + } + .onAppear { + DispatchQueue.main.async { + viewModel.send(.setIsSearching(true)) } } - .task { viewModel.send(.onAppear) } } private var headerView: some View {