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
173 changes: 173 additions & 0 deletions DevLog/Presentation/Common/LoadingState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//
// LoadingState.swift
// DevLog
//
// Created by opfic on 3/16/26.
//

import Foundation

@MainActor
final class LoadingState {
private enum DefaultTarget: Hashable {
case value
}

enum Mode {
case immediate
case delayed
}

private let delay: Duration
private var immediateCountByTarget: [AnyHashable: Int] = [:]
private var delayedCountByTarget: [AnyHashable: Int] = [:]
private var delayedTaskByTarget: [AnyHashable: Task<Void, Never>] = [:]
private var visibleDelayedTargets = Set<AnyHashable>()
private var visibleTargets = Set<AnyHashable>()

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

func begin(
mode: Mode,
update: @escaping @MainActor (Bool) -> Void
) {
begin(target: DefaultTarget.value, mode: mode) { _, isLoading in
update(isLoading)
}
}

func begin<T: Hashable>(
target: T,
mode: Mode,
update: @escaping @MainActor (T, Bool) -> Void
) {
let hashableTarget = AnyHashable(target)
begin(target: hashableTarget, mode: mode) { isLoading in
update(target, isLoading)
}
}

func end(
mode: Mode,
update: @escaping @MainActor (Bool) -> Void
) {
end(target: DefaultTarget.value, mode: mode) { _, isLoading in
update(isLoading)
}
}

func end<T: Hashable>(
target: T,
mode: Mode,
update: @escaping @MainActor (T, Bool) -> Void
) {
let hashableTarget = AnyHashable(target)
end(target: hashableTarget, mode: mode) { isLoading in
update(target, isLoading)
}
}

private func begin(
target: AnyHashable,
mode: Mode,
update: @escaping @MainActor (Bool) -> Void
) {
switch mode {
case .immediate:
immediateCountByTarget[target, default: 0] += 1
setVisibilityIfNeeded(for: target, isVisible: true, update: update)
case .delayed:
delayedCountByTarget[target, default: 0] += 1
scheduleDelayedLoadingIfNeeded(for: target, update: update)
}
}

private func end(
target: AnyHashable,
mode: Mode,
update: @escaping @MainActor (Bool) -> Void
) {
switch mode {
case .immediate:
let count = immediateCountByTarget[target, default: 0]
immediateCountByTarget[target] = max(0, count - 1)
case .delayed:
let count = delayedCountByTarget[target, default: 0]
delayedCountByTarget[target] = max(0, count - 1)
}
updateLoadingVisibility(for: target, update: update)
}

private func scheduleDelayedLoadingIfNeeded(
for target: AnyHashable,
update: @escaping @MainActor (Bool) -> Void
) {
guard delayedTaskByTarget[target] == nil,
!visibleDelayedTargets.contains(target),
0 < delayedCountByTarget[target, default: 0] else { return }
delayedTaskByTarget[target] = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(for: delay)
if Task.isCancelled { return }
await MainActor.run {
self.delayedTaskByTarget[target] = nil
guard 0 < self.delayedCountByTarget[target, default: 0] else { return }
self.visibleDelayedTargets.insert(target)
if self.immediateCountByTarget[target, default: 0] == 0 {
self.setVisibilityIfNeeded(for: target, isVisible: true, update: update)
}
}
}
}

private func updateLoadingVisibility(
for target: AnyHashable,
update: @escaping @MainActor (Bool) -> Void
) {
if 0 < immediateCountByTarget[target, default: 0] {
setVisibilityIfNeeded(for: target, isVisible: true, update: update)
return
}
if visibleDelayedTargets.contains(target) {
if delayedCountByTarget[target, default: 0] == 0 {
visibleDelayedTargets.remove(target)
setVisibilityIfNeeded(for: target, isVisible: false, update: update)
} else {
setVisibilityIfNeeded(for: target, isVisible: true, update: update)
}
return
}
if 0 < delayedCountByTarget[target, default: 0] {
if visibleTargets.contains(target) {
setVisibilityIfNeeded(for: target, isVisible: true, update: update)
} else {
setVisibilityIfNeeded(for: target, isVisible: false, update: update)
}
scheduleDelayedLoadingIfNeeded(for: target, update: update)
return
}
delayedTaskByTarget[target]?.cancel()
delayedTaskByTarget[target] = nil
setVisibilityIfNeeded(for: target, isVisible: false, update: update)
}

private func setVisibilityIfNeeded(
for target: AnyHashable,
isVisible: Bool,
update: @escaping @MainActor (Bool) -> Void
) {
let wasVisible = visibleTargets.contains(target)

if isVisible {
visibleTargets.insert(target)
} else {
visibleTargets.remove(target)
}

if wasVisible != isVisible {
update(isVisible)
}
}
}
23 changes: 17 additions & 6 deletions DevLog/Presentation/ViewModel/AccountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ final class AccountViewModel: Store {
private let fetchProvidersUseCase: FetchAuthProvidersUseCase
private let linkProviderUseCase: LinkAuthProviderUseCase
private let unlinkProviderUseCase: UnlinkAuthProviderUseCase
private let loadingState = LoadingState()

init(
fetchProvidersUseCase: FetchAuthProvidersUseCase,
Expand Down Expand Up @@ -106,11 +107,10 @@ final class AccountViewModel: Store {
}
}
case .link(let provider):
beginLoading(.delayed)
Task {
do {
defer { send(.setLoading(false)) }
send(.setLoading(true))

defer { endLoading(.delayed) }
try await linkProviderUseCase.execute(provider)
send(.setToast(isPresented: true, type: .linkSuccess))

Expand All @@ -122,11 +122,10 @@ final class AccountViewModel: Store {
}
}
case .unlink(let provider):
beginLoading(.delayed)
Task {
do {
defer { send(.setLoading(false)) }
send(.setLoading(true))

defer { endLoading(.delayed) }
try await unlinkProviderUseCase.execute(provider)
send(.setToast(isPresented: true, type: .unlinkSuccess))

Expand Down Expand Up @@ -192,4 +191,16 @@ private extension AccountViewModel {
state.showToast = isPresented
state.toastType = type
}

private func beginLoading(_ mode: LoadingState.Mode) {
loadingState.begin(mode: mode) { [weak self] isLoading in
self?.send(.setLoading(isLoading))
}
}

private func endLoading(_ mode: LoadingState.Mode) {
loadingState.end(mode: mode) { [weak self] isLoading in
self?.send(.setLoading(isLoading))
}
}
}
45 changes: 32 additions & 13 deletions DevLog/Presentation/ViewModel/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ final class HomeViewModel: Store {
case searchView
}

enum LoadingTarget {
enum LoadingTarget: Hashable {
case recentTodos
case webPage
case overlay
Expand All @@ -94,6 +94,7 @@ final class HomeViewModel: Store {
private let undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase
private let fetchTodosUseCase: FetchTodosUseCase
private let fetchWebPagesUseCase: FetchWebPagesUseCase
private let loadingState = LoadingState()
private var deletedWebPageURLString: String?

init(
Expand Down Expand Up @@ -133,10 +134,10 @@ final class HomeViewModel: Store {
func run(_ effect: SideEffect) {
switch effect {
case .addTodo(let todo):
beginLoading(for: .overlay, mode: .delayed)
Task {
do {
defer { send(.setLoading(.overlay, false)) }
send(.setLoading(.overlay, true))
defer { endLoading(for: .overlay, mode: .delayed) }
try await upsertTodoUseCase.execute(todo)
let page = try await fetchRecentTodos()
let items = page.items
Expand All @@ -149,10 +150,10 @@ final class HomeViewModel: Store {
}
}
case .fetchRecentTodos:
beginLoading(for: .recentTodos, mode: .immediate)
Task {
do {
defer { send(.setLoading(.recentTodos, false)) }
send(.setLoading(.recentTodos, true))
defer { endLoading(for: .recentTodos, mode: .immediate) }
let page = try await fetchRecentTodos()
let items = page.items
.filter { $0.createdAt != $0.updatedAt }
Expand All @@ -164,10 +165,10 @@ final class HomeViewModel: Store {
}
}
case .addWebPage(let urlString):
beginLoading(for: .overlay, mode: .delayed)
Task {
do {
defer { send(.setLoading(.overlay, false)) }
send(.setLoading(.overlay, true))
defer { endLoading(for: .overlay, mode: .delayed) }
try await addWebPageUseCase.execute(urlString)
let pages = try await fetchWebPagesUseCase.execute("")
send(.updateWebPages(pages.map { WebPageItem(from: $0) }))
Expand All @@ -176,20 +177,20 @@ final class HomeViewModel: Store {
}
}
case .deleteWebPage(let page, let index):
beginLoading(for: .webPage, mode: .delayed)
Task {
do {
defer { send(.setLoading(.webPage, false)) }
send(.setLoading(.webPage, true))
defer { endLoading(for: .webPage, mode: .delayed) }
try await deleteWebPageUseCase.execute(page.url.absoluteString)
} catch {
send(.restoreWebPage(page, index))
send(.setAlert(isPresented: true, type: .error))
}
}
case .undoDeleteWebPage(let urlString):
beginLoading(for: .webPage, mode: .delayed)
Task {
defer { send(.setLoading(.webPage, false)) }
send(.setLoading(.webPage, true))
defer { endLoading(for: .webPage, mode: .delayed) }

var shouldPresentError = false

Expand All @@ -211,10 +212,10 @@ final class HomeViewModel: Store {
}
}
case .fetchWebPages:
beginLoading(for: .webPage, mode: .immediate)
Task {
do {
defer { send(.setLoading(.webPage, false)) }
send(.setLoading(.webPage, true))
defer { endLoading(for: .webPage, mode: .immediate) }
let pages = try await fetchWebPagesUseCase.execute("")
send(.updateWebPages(pages.map { WebPageItem(from: $0) }))
} catch {
Expand Down Expand Up @@ -411,4 +412,22 @@ private extension HomeViewModel {
cursor: nil
)
}

private func beginLoading(
for target: LoadingTarget,
mode: LoadingState.Mode
) {
loadingState.begin(target: target, mode: mode) { [weak self] target, isLoading in
self?.send(.setLoading(target, isLoading))
}
}

private func endLoading(
for target: LoadingTarget,
mode: LoadingState.Mode
) {
loadingState.end(target: target, mode: mode) { [weak self] target, isLoading in
self?.send(.setLoading(target, isLoading))
}
}
}
Loading