Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
66 changes: 45 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,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(
Expand All @@ -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
}
}
}
Expand All @@ -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 {
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