Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## 🔗 연관된 이슈
> 이슈 번호를 입력해주세요. (예: #12)
> 이슈가 완전히 해결되었다면 아래에 예약어를 남겨주세요.
- closed #이슈번호

## 📝 작업 내용

### 📌 요약

### 🔍 상세

## 📸 영상 / 이미지 (Optional)
2 changes: 1 addition & 1 deletion DevLog/Presentation/Common/LoadingState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ final class LoadingState {
private var visibleDelayedTargets = Set<AnyHashable>()
private var visibleTargets = Set<AnyHashable>()

init(delay: Duration = .milliseconds(500)) {
init(delay: Duration = .seconds(0.3)) {
self.delay = delay
}

Expand Down
12 changes: 6 additions & 6 deletions DevLog/Presentation/ViewModel/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions DevLog/Presentation/ViewModel/LoginViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class LoginViewModel: Store {
}

private let signInUseCase: SignInUseCase
private let loadingState = LoadingState()

private(set) var state = State()

Expand Down Expand Up @@ -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 }
Expand All @@ -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))
}
}
}
4 changes: 2 additions & 2 deletions DevLog/Presentation/ViewModel/ProfileViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
64 changes: 43 additions & 21 deletions DevLog/Presentation/ViewModel/SearchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
private var searchTasks: [SearchTaskKind: Task<Void, Never>] = [:]

init(
fetchWebPagesUseCase: FetchWebPagesUseCase,
Expand Down Expand Up @@ -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):
Expand All @@ -133,10 +133,14 @@ 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
self.endLoading(.immediate)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

검색 작업이 취소되었을 때 defer 블록에서 endLoading(.immediate)이 호출되면, cancelSearch()에서 이미 호출된 endLoading과 중복되어 로딩 카운트가 의도치 않게 추가로 감소할 수 있습니다. 이로 인해 새로운 검색이 시작되었음에도 로딩 인디케이터가 조기에 사라지는 현상이 발생할 수 있으므로, 작업이 취소되지 않은 경우에만 호출하도록 수정이 필요합니다.

                        if !Task.isCancelled {
                            self.endLoading(.immediate)
                        }

}
let searchesTodoOnly = searchesTodoOnly(query)
async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil)
async let webPageItems = fetchWebPageItems(
Expand All @@ -145,12 +149,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
}
}
}
Expand All @@ -165,21 +172,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 {
Expand Down
4 changes: 2 additions & 2 deletions DevLog/Presentation/ViewModel/TodayViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions DevLog/Presentation/ViewModel/TodoDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading