diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..fec87575 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## ๐Ÿ”— ์—ฐ๊ด€๋œ ์ด์Šˆ +> ์ด์Šˆ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. (์˜ˆ: #12) +> ์ด์Šˆ๊ฐ€ ์™„์ „ํžˆ ํ•ด๊ฒฐ๋˜์—ˆ๋‹ค๋ฉด ์•„๋ž˜์— ์˜ˆ์•ฝ์–ด๋ฅผ ๋‚จ๊ฒจ์ฃผ์„ธ์š”. +- closed #์ด์Šˆ๋ฒˆํ˜ธ + +## ๐Ÿ“ ์ž‘์—… ๋‚ด์šฉ + +### ๐Ÿ“Œ ์š”์•ฝ + +### ๐Ÿ” ์ƒ์„ธ + +## ๐Ÿ“ธ ์˜์ƒ / ์ด๋ฏธ์ง€ (Optional) \ No newline at end of file diff --git a/DevLog/Presentation/Common/LoadingState.swift b/DevLog/Presentation/Common/LoadingState.swift index 7a730653..42c35c73 100644 --- a/DevLog/Presentation/Common/LoadingState.swift +++ b/DevLog/Presentation/Common/LoadingState.swift @@ -25,7 +25,7 @@ final class LoadingState { private var visibleDelayedTargets = Set() private var visibleTargets = Set() - init(delay: Duration = .milliseconds(500)) { + init(delay: Duration = .seconds(0.3)) { self.delay = delay } diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index efc26925..e3a5f39d 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -157,10 +157,10 @@ final class HomeViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodoCategoryPreferences: - beginLoading(for: .preferences, mode: .immediate) + beginLoading(for: .preferences, mode: .delayed) Task { do { - defer { endLoading(for: .preferences, mode: .immediate) } + defer { endLoading(for: .preferences, mode: .delayed) } let preferences = try await fetchPreferencesUseCase.execute() send(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:)))) } catch { @@ -192,10 +192,10 @@ final class HomeViewModel: Store { } } case .fetchRecentTodos: - beginLoading(for: .recentTodos, mode: .immediate) + beginLoading(for: .recentTodos, mode: .delayed) Task { do { - defer { endLoading(for: .recentTodos, mode: .immediate) } + defer { endLoading(for: .recentTodos, mode: .delayed) } let page = try await fetchRecentTodos() let items = page.items .filter { $0.createdAt != $0.updatedAt } @@ -254,10 +254,10 @@ final class HomeViewModel: Store { } } case .fetchWebPages: - beginLoading(for: .webPage, mode: .immediate) + beginLoading(for: .webPage, mode: .delayed) Task { do { - defer { endLoading(for: .webPage, mode: .immediate) } + defer { endLoading(for: .webPage, mode: .delayed) } let pages = try await fetchWebPagesUseCase.execute("") send(.updateWebPages(pages.map { WebPageItem(from: $0) })) } catch { diff --git a/DevLog/Presentation/ViewModel/LoginViewModel.swift b/DevLog/Presentation/ViewModel/LoginViewModel.swift index 4ed50ec2..98470d49 100644 --- a/DevLog/Presentation/ViewModel/LoginViewModel.swift +++ b/DevLog/Presentation/ViewModel/LoginViewModel.swift @@ -27,6 +27,7 @@ final class LoginViewModel: Store { } private let signInUseCase: SignInUseCase + private let loadingState = LoadingState() private(set) var state = State() @@ -54,12 +55,12 @@ final class LoginViewModel: Store { } func run(_ effect: SideEffect) { - send(.setLoading(true)) switch effect { case .signIn(let authProvider): + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } + defer { endLoading(.immediate) } try await self.signInUseCase.execute(authProvider) } catch { if error.isSocialLoginCancelled { return } @@ -79,4 +80,16 @@ private extension LoginViewModel { state.alertMessage = String(localized: "common_error_message") state.showAlert = isPresented } + + func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 3d95a557..16ee4695 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -201,10 +201,10 @@ final class ProfileViewModel: Store { } } case .fetchActivityQuarter(let quarterStart): - beginLoading(mode: .immediate) + beginLoading(mode: .delayed) Task { do { - defer { endLoading(mode: .immediate) } + defer { endLoading(mode: .delayed) } let quarterActivityData = try await fetchQuarterActivityData(from: quarterStart) send( .setActivityQuarter( diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index f6386d4f..7738db5b 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -118,10 +118,10 @@ final class PushNotificationListViewModel: Store { if cursor == nil { stopObservingNotifications() } - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let existingCount = cursor == nil ? 0 : self.state.notifications.count let page = try await fetchUseCase.execute(query, cursor: cursor) @@ -160,7 +160,7 @@ final class PushNotificationListViewModel: Store { beginLoading(.delayed) Task { // endLoading(.delayed)๋ฅผ defer๋กœ ๋‘์ง€ ์•Š๋Š” ์ด์œ  - // send(.fetchNotifications)๊ฐ€ ๊ฐ™์€ ํ„ด์—์„œ beginLoading(.immediate)๋ฅผ ๋จผ์ € ์˜ฌ๋ฆฐ ๋’ค + // send(.fetchNotifications)๊ฐ€ ๊ฐ™์€ ํ„ด์—์„œ beginLoading(.delayed)๋ฅผ ๋จผ์ € ์˜ฌ๋ฆฐ ๋’ค // delayed ๋กœ๋”ฉ์„ ๋‚ด๋ ค์•ผ ๊ฐ™์€ isLoading์ด ๋Š๊ธฐ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ do { try await undoDeleteUseCase.execute(notificationId) diff --git a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift index f40e0cb3..6bbaf64f 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift @@ -107,10 +107,10 @@ final class PushNotificationSettingsViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchPushNotificationSettings: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let settings = try await fetchPushSettingsUseCase.execute() self.send(.setPushNotificationEnable(settings.isEnabled)) if let hour = settings.scheduledTime.hour, diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 6bfc606b..42b91ae2 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -43,16 +43,22 @@ final class SearchViewModel: Store { case fetch(String) } + private enum SearchTaskKind: Hashable { + case debounce + case request + } + private(set) var state: State = .init() private let fetchWebPagesUseCase: FetchWebPagesUseCase private let fetchTodosUseCase: FetchTodosUseCase private let fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase private let updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase + private let loadingState = LoadingState() let contentsLimit: Int = 5 private let maxRecentQueries = 20 private let searchDebounceDelay: Double = 0.4 - private var searchDebounceTask: Task? + private var searchTasks: [SearchTaskKind: Task] = [:] init( fetchWebPagesUseCase: FetchWebPagesUseCase, @@ -104,22 +110,16 @@ final class SearchViewModel: Store { state.showAllWebPages = false let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - cancelDebounce() + cancelSearch() state.webPages = [] state.todos = [] - state.isLoading = false } else { - state.isLoading = true - scheduleDebouncedQuery(query) + cancelSearch() + beginLoading(.immediate) + scheduleDebouncedFetch(trimmed) } case .applySearchQuery(let query): - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.webPages = [] - state.todos = [] - } else { - effects = [.fetch(trimmed)] - } + effects = [.fetch(query)] case .setShowAllTodos(let shouldShowAll): state.showAllTodos = shouldShowAll case .setShowAllWebPages(let shouldShowAll): @@ -133,10 +133,16 @@ final class SearchViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetch(let query): - Task { + searchTasks[.request]?.cancel() + let requestTask = Task { [weak self] in + guard let self else { return } do { - send(.setLoading(true)) - defer { send(.setLoading(false)) } + defer { + self.searchTasks[.request] = nil + if !Task.isCancelled { + self.endLoading(.immediate) + } + } let searchesTodoOnly = searchesTodoOnly(query) async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) async let webPageItems = fetchWebPageItems( @@ -145,12 +151,15 @@ final class SearchViewModel: Store { ) let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } let resolvedWebPageItems = try await webPageItems + if Task.isCancelled { return } send(.fetchTodos(todoItems)) send(.fetchWebPage(resolvedWebPageItems)) } catch { + if error is CancellationError { return } send(.setAlert(true)) } } + searchTasks[.request] = requestTask } } } @@ -165,21 +174,36 @@ private extension SearchViewModel { state.showAlert = isPresented } - func scheduleDebouncedQuery(_ query: String) { - searchDebounceTask?.cancel() - searchDebounceTask = Task { [weak self] in + func scheduleDebouncedFetch(_ query: String) { + searchTasks[.debounce]?.cancel() + let debounceTask = Task { [weak self] in guard let self else { return } try? await Task.sleep(for: .seconds(searchDebounceDelay)) if Task.isCancelled { return } await MainActor.run { + self.searchTasks[.debounce] = nil self.send(.applySearchQuery(query)) } } + searchTasks[.debounce] = debounceTask + } + + func cancelSearch() { + searchTasks.values.forEach { $0.cancel() } + searchTasks = [:] + endLoading(.immediate) } - func cancelDebounce() { - searchDebounceTask?.cancel() - searchDebounceTask = nil + func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } } func searchesTodoOnly(_ query: String) -> Bool { diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index d14bff96..a4e7b113 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -189,10 +189,10 @@ final class TodayViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodos: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } async let todosWithDueDatePage = fetchTodosUseCase.execute( TodoQuery( completionFilter: .incomplete, diff --git a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift index 3f5e705b..6ceb674f 100644 --- a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift @@ -95,10 +95,10 @@ final class TodoDetailViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodo: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let todo = try await fetchTodoUseCase.execute(todoId) send(.setTodo(todo)) } catch { diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 50a7cd9e..a303f1f6 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -52,7 +52,7 @@ final class TodoListViewModel: Store { case upsertTodo(Todo) // Run - case setSearchQuery(String) + case triggerSearch(String) case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) @@ -74,9 +74,12 @@ final class TodoListViewModel: Store { case togglePinned(TodoListItem) } + private enum SearchTaskKind: Hashable { + case debounce + case request + } + 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 @@ -85,6 +88,8 @@ final class TodoListViewModel: Store { private let loadingState = LoadingState() private var undoDeleteTodoId: String? private var nextCursor: TodoCursor? + private var searchTasks: [SearchTaskKind: Task] = [:] + private let searchDebounceDelay: Double = 0.4 init( fetchTodosUseCase: FetchTodosUseCase, @@ -129,7 +134,7 @@ final class TodoListViewModel: Store { case .onAppear, .loadNextPage, .setSearchText, .setToast, .upsertTodo: effects = reduceByView(action, state: &state) - case .setSearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, + case .triggerSearch, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, .restoreTodo, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -142,10 +147,10 @@ final class TodoListViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetch: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nil) send(.resetPagination) send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) @@ -156,10 +161,10 @@ final class TodoListViewModel: Store { } } case .loadNextPage: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nextCursor) send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) let hasMore = page.items.count == state.query.pageSize && page.nextCursor != nil @@ -169,17 +174,26 @@ final class TodoListViewModel: Store { } } case .search(let keyword): - beginLoading(.immediate) - Task { + searchTasks[.request]?.cancel() + let requestTask = Task { [weak self] in + guard let self else { return } do { - defer { endLoading(.immediate) } + defer { + self.searchTasks[.request] = nil + if !Task.isCancelled { + self.endLoading(.immediate) + } + } let query = TodoQuery(category: state.category, keyword: keyword) let page = try await fetchTodosUseCase.execute(query, cursor: nil) + if Task.isCancelled { return } send(.fetchSearchResults(page.items.compactMap { TodoListItem(from: $0) })) } catch { + if error is CancellationError { return } send(.setAlert(true)) } } + searchTasks[.request] = requestTask case .upsert(let item): beginLoading(.delayed) Task { @@ -242,7 +256,7 @@ final class TodoListViewModel: Store { beginLoading(.delayed) Task { // endLoading(.delayed)๋ฅผ defer๋กœ ๋‘์ง€ ์•Š๋Š” ์ด์œ  - // send(.refresh)๊ฐ€ ๊ฐ™์€ ํ„ด์—์„œ beginLoading(.immediate)๋ฅผ ๋จผ์ € ์˜ฌ๋ฆฐ ๋’ค + // send(.refresh)๊ฐ€ ๊ฐ™์€ ํ„ด์—์„œ beginLoading(.delayed)๋ฅผ ๋จผ์ € ์˜ฌ๋ฆฐ ๋’ค // delayed ๋กœ๋”ฉ์„ ๋‚ด๋ ค์•ผ ๊ฐ™์€ isLoading์ด ๋Š๊ธฐ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ do { try await undoDeleteTodoUseCase.execute(todoId) @@ -298,7 +312,7 @@ private extension TodoListViewModel { case .setIsSearching(let value): state.isSearching = value if !value { - cancelDebounce() + cancelSearch() state.searchText = "" state.searchResults = [] state.showAllSearchResults = false @@ -332,12 +346,12 @@ private extension TodoListViewModel { state.showAllSearchResults = false let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - cancelDebounce() + cancelSearch() state.searchResults = [] - state.isLoading = false } else { - state.isLoading = true - scheduleDebouncedQuery(text) + cancelSearch() + beginLoading(.immediate) + scheduleDebouncedSearch(trimmed) } case .setToast(let isPresented): setToast(&state, isPresented: isPresented) @@ -352,13 +366,8 @@ 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 .triggerSearch(let query): + return [.search(query)] case .fetchSearchResults(let items): state.searchResults = items case .didToggleCompleted(let todo): @@ -418,21 +427,24 @@ private extension TodoListViewModel { state.showToast = isPresented } - func scheduleDebouncedQuery(_ query: String) { - searchDebounceTask?.cancel() - searchDebounceTask = Task { [weak self] in + func scheduleDebouncedSearch(_ query: String) { + searchTasks[.debounce]?.cancel() + let debounceTask = 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)) + self.searchTasks[.debounce] = nil + self.send(.triggerSearch(query)) } } + searchTasks[.debounce] = debounceTask } - func cancelDebounce() { - searchDebounceTask?.cancel() - searchDebounceTask = nil + func cancelSearch() { + searchTasks.values.forEach { $0.cancel() } + searchTasks = [:] + endLoading(.immediate) } private func beginLoading(_ mode: LoadingState.Mode) {