diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Dependencies.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Dependencies.swift new file mode 100644 index 00000000..a346296d --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Dependencies.swift @@ -0,0 +1,101 @@ +// +// HomeFeature+Dependencies.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import ComposableArchitecture +import DevLogDomain + +extension DependencyValues { + var homeUpdateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase { + get { self[HomeUpdatePreferencesUseCaseKey.self] } + set { self[HomeUpdatePreferencesUseCaseKey.self] = newValue } + } + + var homeAddWebPageUseCase: AddWebPageUseCase { + get { self[HomeAddWebPageUseCaseKey.self] } + set { self[HomeAddWebPageUseCaseKey.self] = newValue } + } + + var homeDeleteWebPageUseCase: DeleteWebPageUseCase { + get { self[HomeDeleteWebPageUseCaseKey.self] } + set { self[HomeDeleteWebPageUseCaseKey.self] = newValue } + } + + var homeUndoDeleteWebPageUseCase: UndoDeleteWebPageUseCase { + get { self[HomeUndoDeleteWebPageUseCaseKey.self] } + set { self[HomeUndoDeleteWebPageUseCaseKey.self] = newValue } + } + + var homeFetchTodosUseCase: FetchTodosUseCase { + get { self[HomeFetchTodosUseCaseKey.self] } + set { self[HomeFetchTodosUseCaseKey.self] = newValue } + } + + var homeFetchWebPagesUseCase: FetchWebPagesUseCase { + get { self[HomeFetchWebPagesUseCaseKey.self] } + set { self[HomeFetchWebPagesUseCaseKey.self] = newValue } + } +} + +private enum HomeUpdatePreferencesUseCaseKey: DependencyKey { + static var liveValue: UpdateTodoCategoryPreferencesUseCase { + preconditionFailure("UpdateTodoCategoryPreferencesUseCase must be provided.") + } + + static var testValue: UpdateTodoCategoryPreferencesUseCase { + liveValue + } +} + +private enum HomeAddWebPageUseCaseKey: DependencyKey { + static var liveValue: AddWebPageUseCase { + preconditionFailure("AddWebPageUseCase must be provided.") + } + + static var testValue: AddWebPageUseCase { + liveValue + } +} + +private enum HomeDeleteWebPageUseCaseKey: DependencyKey { + static var liveValue: DeleteWebPageUseCase { + preconditionFailure("DeleteWebPageUseCase must be provided.") + } + + static var testValue: DeleteWebPageUseCase { + liveValue + } +} + +private enum HomeUndoDeleteWebPageUseCaseKey: DependencyKey { + static var liveValue: UndoDeleteWebPageUseCase { + preconditionFailure("UndoDeleteWebPageUseCase must be provided.") + } + + static var testValue: UndoDeleteWebPageUseCase { + liveValue + } +} + +private enum HomeFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } + + static var testValue: FetchTodosUseCase { + liveValue + } +} + +private enum HomeFetchWebPagesUseCaseKey: DependencyKey { + static var liveValue: FetchWebPagesUseCase { + preconditionFailure("FetchWebPagesUseCase must be provided.") + } + + static var testValue: FetchWebPagesUseCase { + liveValue + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift new file mode 100644 index 00000000..239a0831 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -0,0 +1,225 @@ +// +// HomeFeature+Effects.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +extension HomeFeature { + private enum CancelID: Hashable { + case delayedTodoEditor + case networkConnectivity + } + + func observeNetworkConnectivityEffect() -> Effect { + .publisher { [networkConnectivityUseCase] in + networkConnectivityUseCase.observe() + .receive(on: DispatchQueue.main) + .map { .store(.networkStatusChanged($0)) } + } + .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) + } + + func fetchTodoCategoryPreferencesEffect() -> Effect { + .run { [fetchPreferencesUseCase] send in + await send(.loading(.begin(target: LoadingTarget.preferences.target, mode: .immediate))) + do { + let preferences = try await fetchPreferencesUseCase.execute() + await send(.store(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:))))) + } catch { + await send(.store(.setAlert(isPresented: true, type: .error))) + } + await send(.loading(.end(target: LoadingTarget.preferences.target, mode: .immediate))) + } + } + + func fetchRecentTodosEffect() -> Effect { + .run { [fetchTodosUseCase] send in + await send(.loading(.begin(target: LoadingTarget.recentTodos.target, mode: .immediate))) + do { + let page = try await fetchRecentTodos(fetchTodosUseCase: fetchTodosUseCase) + let items = page.items + .filter { $0.createdAt != $0.updatedAt } + .prefix(5) + .compactMap(RecentTodoItem.init(from:)) + await send(.store(.updateRecentTodos(Array(items)))) + } catch { + await send(.store(.setAlert(isPresented: true, type: .error))) + } + await send(.loading(.end(target: LoadingTarget.recentTodos.target, mode: .immediate))) + } + } + + func fetchWebPagesEffect() -> Effect { + .run { [fetchWebPagesUseCase] send in + await send(.loading(.begin(target: LoadingTarget.webPage.target, mode: .immediate))) + do { + let pages = try await fetchWebPagesUseCase.execute("") + await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) + } catch { + await send(.store(.setAlert(isPresented: true, type: .error))) + } + await send(.loading(.end(target: LoadingTarget.webPage.target, mode: .immediate))) + } + } + + func addWebPageEffect(_ urlString: String) -> Effect { + .run { [addWebPageUseCase, fetchWebPagesUseCase, trackAnalyticsEventUseCase] send in + await send(.loading(.begin(target: LoadingTarget.overlay.target, mode: .delayed))) + do { + try await addWebPageUseCase.execute(urlString) + trackAnalyticsEventUseCase?.execute(.webPageCreate) + let pages = try await fetchWebPagesUseCase.execute("") + await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) + } catch { + await send(.store(.setAlert(isPresented: true, type: .error))) + } + await send(.loading(.end(target: LoadingTarget.overlay.target, mode: .delayed))) + } + } + + func deleteWebPageEffect(_ page: WebPageItem) -> Effect { + .run { [deleteWebPageUseCase] send in + do { + try await deleteWebPageUseCase.execute(page.url.absoluteString) + } catch { + await send(.store(.handleWebPageDeleteFailure(page.id))) + await send(.store(.setAlert(isPresented: true, type: .error))) + } + } + } + + func undoDeleteWebPageEffect(_ urlString: String) -> Effect { + .run { [undoDeleteWebPageUseCase, addWebPageUseCase] send in + do { + try await undoDeleteWebPageUseCase.execute(urlString) + try await addWebPageUseCase.execute(urlString) + } catch { + if let webPageURL = URL(string: urlString) { + await send(.store(.setWebPageHidden(webPageURL, true))) + } + await send(.store(.setAlert(isPresented: true, type: .error))) + } + } + } + + func updateTodoCategoryPreferencesEffect(_ items: [TodoCategoryItem]) -> Effect { + .run { [updatePreferencesUseCase] send in + do { + try await updatePreferencesUseCase.execute(items.map(\.preference)) + } catch { + await send(.store(.setAlert(isPresented: true, type: .error))) + } + } + } + + func delayedTodoEditorEffect() -> Effect { + .run { [clock] send in + // iOS 17에서 시트 dismiss 직후 fullScreenCover를 바로 올리지 않도록 하기 위해서 0.1초 딜레이 + try await clock.sleep(for: .seconds(0.1)) + await send(.store(.setPresentation(.todoEditor, true))) + } + .cancellable(id: CancelID.delayedTodoEditor, cancelInFlight: true) + } + + func fetchRecentTodos(fetchTodosUseCase: FetchTodosUseCase) async throws -> TodoPage { + try await fetchTodosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100 + ), + cursor: nil + ) + } + + static func setPresentation( + _ state: inout State, + presentation: Presentation, + isPresented: Bool + ) { + switch presentation { + case .todoEditor: + state.fullScreenCover = isPresented ? state.selectedTodoCategory.map(FullScreenCoverState.todoEditor) : nil + if !isPresented { + state.selectedTodoCategory = nil + } + case .contentPicker: + state.sheet = isPresented ? .contentPicker(.init()) : state.showContentPicker ? nil : state.sheet + case .searchView: + state.fullScreenCover = isPresented ? .search : nil + } + } + + static func setAlert( + _ state: inout State, + isPresented: Bool, + type: AlertType? + ) { + guard isPresented, let type else { + state.alert = nil + return + } + + state.alert = alertState(for: type) + } + + static func alertState(for type: AlertType) -> AlertState { + let title: String + let message: String + + switch type { + case .invalidURL: + title = String(localized: "home_invalid_url_title") + message = String(localized: "home_invalid_url_message") + case .error: + title = String(localized: "common_error_title") + message = String(localized: "common_error_message") + } + + return AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) + } + } + + static func syncRecentTodos( + _ recentTodos: [RecentTodoItem], + preferences: [TodoCategoryItem] + ) -> [RecentTodoItem] { + recentTodos.map { recentTodo in + guard let item = preferences.first(where: { + $0.category.storageValue == recentTodo.category.storageValue + }) else { + return recentTodo + } + + var recentTodo = recentTodo + recentTodo.category = item.category + return recentTodo + } + } + + static func normalizedWebPageURL(_ input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed == "https://" || trimmed == "http://" { + return nil + } + if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") { + return trimmed + } + return "https://" + trimmed + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift new file mode 100644 index 00000000..04c6b94a --- /dev/null +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -0,0 +1,357 @@ +// +// HomeFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/14/26. +// + +import ComposableArchitecture +import DevLogDomain +import Foundation + +@Reducer +struct HomeFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Presents var sheet: SheetState? + @Presents var fullScreenCover: FullScreenCoverState? + var preferences = [TodoCategoryItem]() + var recentTodos = [RecentTodoItem]() + var webPages = [WebPageItem]() + var needsWebPageRefresh = false + var isNetworkConnected = true + var webPageURLInput = "https://" + var selectedTodoCategory: TodoCategory? + var deletedWebPageURLString: String? + var loading = LoadingFeature.State() + + var showContentPicker: Bool { sheet?.contentPickerState != nil } + var showTodoEditor: Bool { fullScreenCover?.destination == .todoEditor } + + var isPreferencesLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.preferences.target) + } + + var isRecentTodosLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.recentTodos.target) + } + + var isWebPageLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.webPage.target) + } + + var isAppending: Bool { + loading.visibleTargets.contains(LoadingTarget.overlay.target) + } + } + + enum Action: Equatable { + case alert(PresentationAction) + case sheet(PresentationAction) + case fullScreenCover(PresentationAction) + case view(ViewAction) + case store(StoreAction) + case loading(LoadingFeature.Action) + + enum ViewAction: Equatable { + case startObserving + case fetchData + case refreshRecentTodos + case refreshWebPages + case finishDeleteWebPageToast(String) + case tapTodoCategory(TodoCategory) + case orderTodoCategory([TodoCategoryItem]) + case updateWebPageURLInput(String) + case addWebPage + case deleteWebPage(WebPageItem) + case undoDeleteWebPage + } + + enum StoreAction: Equatable { + case networkStatusChanged(Bool) + case setSheet(SheetState?) + case setPresentation(Presentation, Bool) + case setAlert(isPresented: Bool, type: AlertType? = nil) + case setWebPageHidden(URL, Bool) + case handleWebPageDeleteFailure(URL) + case setTodoCategory([TodoCategoryItem]) + case updateRecentTodos([RecentTodoItem]) + case updateWebPages([WebPageItem]) + } + } + + enum AlertType: Equatable { + case invalidURL + case error + } + + @ObservableState + struct ContentPickerState: Equatable { + @Presents var webPageInput: WebPageInputState? + } + + @ObservableState + struct WebPageInputState: Equatable, Identifiable { + let id = UUID() + } + + @ObservableState + @CasePathable + enum SheetState: Equatable { + case reorderTodo + case contentPicker(ContentPickerState) + + var contentPickerState: ContentPickerState? { + get { + guard case .contentPicker(let state) = self else { return nil } + return state + } + set { + guard let newValue else { return } + self = .contentPicker(newValue) + } + } + } + + @CasePathable + enum Sheet: Equatable { + case tapCloseButton + case contentPicker(ContentPicker) + + @CasePathable + enum ContentPicker: Equatable { + case tapWebPageInput + case webPageInput(PresentationAction) + } + } + + @ObservableState + struct FullScreenCoverState: Equatable { + var destination: Destination + var selectedTodoCategory: TodoCategory? + + enum Destination: Equatable { + case todoEditor + case search + } + + static func todoEditor(_ category: TodoCategory) -> Self { + Self(destination: .todoEditor, selectedTodoCategory: category) + } + + static let search = Self(destination: .search) + } + + enum Presentation: Equatable { + case todoEditor + case contentPicker + case searchView + } + + enum LoadingTarget: Hashable { + case preferences + case recentTodos + case webPage + case overlay + + var target: LoadingFeature.Target { + switch self { + case .preferences: + return LoadingFeature.Target("home.preferences") + case .recentTodos: + return LoadingFeature.Target("home.recentTodos") + case .webPage: + return LoadingFeature.Target("home.webPage") + case .overlay: + return LoadingFeature.Target("home.overlay") + } + } + } + + @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase + @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase + @Dependency(\.homeAddWebPageUseCase) var addWebPageUseCase + @Dependency(\.homeDeleteWebPageUseCase) var deleteWebPageUseCase + @Dependency(\.homeUndoDeleteWebPageUseCase) var undoDeleteWebPageUseCase + @Dependency(\.homeFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.homeFetchWebPagesUseCase) var fetchWebPagesUseCase + @Dependency(\.networkConnectivityUseCase) var networkConnectivityUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.continuousClock) var clock + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + Reduce { state, action in + switch action { + case .alert: + break + case .fullScreenCover(.dismiss): + state.fullScreenCover = nil + state.selectedTodoCategory = nil + case .fullScreenCover: + break + case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): + state.sheet = nil + case .sheet: + break + case .view(let action): + return reduce(action, state: &state) + case .store(let action): + return reduce(action, state: &state) + case .loading: + break + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$sheet, action: \.sheet) { + HomeSheetFeature() + } + } +} + +private extension HomeFeature { + func reduce( + _ action: Action.ViewAction, + state: inout State + ) -> Effect { + switch action { + case .startObserving: + return observeNetworkConnectivityEffect() + case .fetchData: + return .merge( + fetchTodoCategoryPreferencesEffect(), + fetchRecentTodosEffect(), + fetchWebPagesEffect() + ) + case .refreshRecentTodos: + return fetchRecentTodosEffect() + case .refreshWebPages: + return fetchWebPagesEffect() + case .finishDeleteWebPageToast(let urlString): + state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden } + if state.deletedWebPageURLString == urlString { + state.deletedWebPageURLString = nil + } + case .tapTodoCategory(let category): + state.selectedTodoCategory = category + state.sheet = nil + return delayedTodoEditorEffect() + case .orderTodoCategory(let preferences): + state.preferences = preferences + state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) + state.sheet = nil + return updateTodoCategoryPreferencesEffect(preferences) + case .updateWebPageURLInput(let text): + state.webPageURLInput = text + case .addWebPage: + guard let normalizedURL = Self.normalizedWebPageURL(state.webPageURLInput) else { + Self.setAlert(&state, isPresented: true, type: .invalidURL) + return .none + } + state.sheet = nil + Self.setAlert(&state, isPresented: false, type: nil) + return addWebPageEffect(normalizedURL) + case .deleteWebPage(let page): + guard let index = state.webPages.firstIndex(where: { $0.id == page.id }) else { + return .none + } + state.deletedWebPageURLString = page.url.absoluteString + state.webPages[index].isHidden = true + return deleteWebPageEffect(page) + case .undoDeleteWebPage: + guard let urlString = state.deletedWebPageURLString else { return .none } + if let index = state.webPages.firstIndex(where: { $0.url.absoluteString == urlString }) { + state.webPages[index].isHidden = false + } + state.deletedWebPageURLString = nil + return undoDeleteWebPageEffect(urlString) + } + + return .none + } + + func reduce( + _ action: Action.StoreAction, + state: inout State + ) -> Effect { + switch action { + case .networkStatusChanged(let isConnected): + state.isNetworkConnected = isConnected + case .setSheet(let sheet): + state.sheet = sheet + case .setPresentation(let presentation, let isPresented): + Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) + case .setAlert(let isPresented, let type): + Self.setAlert(&state, isPresented: isPresented, type: type) + case .setWebPageHidden(let webPageURL, let isHidden): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = isHidden + } + case .handleWebPageDeleteFailure(let webPageURL): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = false + } else { + state.needsWebPageRefresh = true + } + case .setTodoCategory(let preferences): + state.preferences = preferences + state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) + case .updateRecentTodos(let todos): + state.recentTodos = todos + case .updateWebPages(let pages): + state.webPages = pages + state.needsWebPageRefresh = false + } + + return .none + } +} + +private struct HomeSheetFeature: Reducer { + typealias State = HomeFeature.SheetState + typealias Action = HomeFeature.Sheet + + var body: some ReducerOf { + EmptyReducer() + .ifCaseLet(\.contentPicker, action: \.contentPicker) { + HomeContentPickerFeature() + } + } +} + +private struct HomeContentPickerFeature: Reducer { + typealias State = HomeFeature.ContentPickerState + typealias Action = HomeFeature.Sheet.ContentPicker + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .tapWebPageInput: + state.webPageInput = .init() + case .webPageInput(.dismiss): + state.webPageInput = nil + case .webPageInput: + break + } + + return .none + } + .ifLet(\.$webPageInput, action: \.webPageInput) { + HomeWebPageInputFeature() + } + } +} + +private struct HomeWebPageInputFeature: Reducer { + typealias State = HomeFeature.WebPageInputState + typealias Action = Never + + var body: some ReducerOf { + EmptyReducer() + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 82242d23..40b122b1 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -6,15 +6,26 @@ // import SwiftUI +import ComposableArchitecture import DevLogDomain struct HomeView: View { @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) + @Bindable var store: StoreOf let coordinator: HomeViewCoordinator let isCompactLayout: Bool + init( + coordinator: HomeViewCoordinator, + isCompactLayout: Bool + ) { + self.coordinator = coordinator + self.isCompactLayout = isCompactLayout + self.store = coordinator.store + } + var body: some View { List { todoSection @@ -24,96 +35,22 @@ struct HomeView: View { .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_home")) .toolbar { toolbar } - .sheet(isPresented: Binding( - get: { coordinator.viewModel.state.reorderTodo }, - set: { coordinator.viewModel.send(.setPresentation(.reorderTodo, $0)) } - )) { - CategoryManageView( - preferences: coordinator.viewModel.state.preferences, - onDismiss: { array in - coordinator.viewModel.send(.setPresentation(.reorderTodo, false)) - withAnimation { - coordinator.viewModel.send(.orderTodoCategory(array)) - } - } - ) - } - .sheet(isPresented: Binding( - get: { coordinator.viewModel.state.showContentPicker }, - set: { _, _ in } - )) { - contentPicker - } - .fullScreenCover(isPresented: Binding( - get: { coordinator.viewModel.state.showTodoEditor }, - set: { coordinator.viewModel.send(.setPresentation(.todoEditor, $0)) } - )) { - if let selectedCategory = coordinator.viewModel.state.selectedTodoCategory { - TodoEditorView( - store: coordinator.makeTodoEditorStore(category: selectedCategory), - onCreateSuccess: { - coordinator.viewModel.send(.setPresentation(.todoEditor, false)) - coordinator.viewModel.send(.fetchData) - } - ) - } - } - .fullScreenCover(isPresented: Binding( - get: { coordinator.viewModel.state.showSearchView }, - set: { coordinator.viewModel.send(.setPresentation(.searchView, $0)) } - )) { - SearchView(store: coordinator.makeSearchStore()) - } - .alert( - coordinator.viewModel.state.alertTitle, - isPresented: Binding( - get: { coordinator.viewModel.state.showAlert }, - set: { coordinator.viewModel.send(.setAlert(isPresented: $0)) } - ) - ) { - alertButtons - } message: { - Text(coordinator.viewModel.state.alertMessage) - } + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet), content: sheetContent) + .fullScreenCover(item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover), content: coverContent) .overlay { - if coordinator.viewModel.state.isAppending { + if store.isAppending { LoadingView() } } } - @ViewBuilder - private var alertButtons: some View { - switch coordinator.viewModel.state.alertType { - case .webPageInput: - TextField( - "https://", - text: Binding( - get: { coordinator.viewModel.state.webPageURLInput }, - set: { coordinator.viewModel.send(.updateWebPageURLInput($0)) } - ) - ) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - Button(String(localized: "home_add")) { - coordinator.viewModel.send(.addWebPage) - } - Button(String(localized: "common_cancel"), role: .cancel) { - coordinator.viewModel.send(.setAlert(isPresented: false)) - } - case .invalidURL, .error, .none: - Button(String(localized: "common_close"), role: .cancel) { - coordinator.viewModel.send(.setAlert(isPresented: false)) - } - } - } - private var todoSection: some View { Section(content: { - if coordinator.viewModel.state.isPreferencesLoading { + if store.isPreferencesLoading { LoadingView() } else { - let preferences = coordinator.viewModel.state.preferences + let preferences = store.preferences ForEach(preferences.filter { $0.isVisible }, id: \.id) { item in todoCategoryRow(item) .listRowInsets((EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))) @@ -127,7 +64,7 @@ struct HomeView: View { .bold() Spacer() Button(action: { - coordinator.viewModel.send(.setPresentation(.reorderTodo, true)) + store.send(.store(.setSheet(.reorderTodo))) }) { Image(systemName: "ellipsis") .font(.title2) @@ -140,9 +77,9 @@ struct HomeView: View { private var recentTodoSection: some View { Section { - if coordinator.viewModel.state.isRecentTodosLoading { + if store.isRecentTodosLoading { LoadingView() - } else if coordinator.viewModel.state.recentTodos.isEmpty { + } else if store.recentTodos.isEmpty { HStack { Spacer() Text(String(localized: "home_recent_empty")) @@ -150,7 +87,7 @@ struct HomeView: View { Spacer() } } else { - ForEach(coordinator.viewModel.state.recentTodos, id: \.id) { todo in + ForEach(store.recentTodos, id: \.id) { todo in recentTodoRow(todo) } } @@ -167,13 +104,13 @@ struct HomeView: View { private var webPageSection: some View { Section { - let webPages = coordinator.viewModel.state.webPages.filter { !$0.isHidden } - if coordinator.viewModel.state.isWebPageLoading { + let webPages = store.webPages.filter { !$0.isHidden } + if store.isWebPageLoading { LoadingView() .id(UUID()) // id 부여를 통해 렌더링 강제 - } else if coordinator.viewModel.state.needsWebPageRefresh { + } else if store.needsWebPageRefresh { Button { - coordinator.viewModel.send(.refreshWebPages) + store.send(.view(.refreshWebPages)) } label: { HStack { Spacer() @@ -212,24 +149,139 @@ struct HomeView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - coordinator.viewModel.send(.setPresentation(.contentPicker, true)) + store.send(.store(.setPresentation(.contentPicker, true))) } label: { Image(systemName: "plus") } - .disabled(!coordinator.viewModel.state.isNetworkConnected) + .disabled(!store.isNetworkConnected) } if #available(iOS 26.0, *) { ToolbarSpacer(.fixed, placement: .topBarTrailing) } ToolbarItemGroup(placement: .topBarTrailing) { Button { - coordinator.viewModel.send(.setPresentation(.searchView, true)) + store.send(.store(.setPresentation(.searchView, true))) } label: { Image(systemName: "magnifyingglass") } } } + @ViewBuilder + private func sheetContent(_ sheetStore: Store) -> some View { + if let contentPickerStore = sheetStore.scope(state: \.contentPickerState, action: \.contentPicker) { + @Bindable var contentPickerStore = contentPickerStore + NavigationStack { + List { + Section { + if store.isPreferencesLoading { + LoadingView() + } else { + let preferences = store.preferences.filter(\.isVisible) + ForEach(preferences, id: \.id) { item in + Button { + openTodoEditor(for: item.category) + } label: { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + } + } header: { + Text("TODO") + .foregroundStyle(Color(.label)) + } + + Section { + Button { + contentPickerStore.send(.tapWebPageInput) + } label: { + labelImage( + text: "URL", + systemName: "globe", + imageColor: .blue + ) + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } header: { + Text("Web Page") + .foregroundStyle(Color(.label)) + } + } + .navigationDestination( + item: $contentPickerStore.scope(state: \.webPageInput, action: \.webPageInput) + ) { _ in + Form { + Section { + TextField( + "https://", + text: Binding( + get: { store.webPageURLInput }, + set: { store.send(.view(.updateWebPageURLInput($0))) } + ) + ) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } footer: { + Text(String(localized: "home_webpage_input_message")) + } + } + .scrollDisabled(true) + .navigationTitle(Text(String(localized: "home_webpage_input_title"))) + .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "home_add")) { + store.send(.view(.addWebPage)) + } + } + } + } + .navigationTitle(Text(String(localized: "nav_home_content"))) + .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + store.send(.sheet(.presented(.tapCloseButton))) + } label: { + Image(systemName: "xmark") + .bold() + } + } + } + } + } else { + CategoryManageView( + preferences: store.preferences, + onDismiss: { array in + store.send(.view(.orderTodoCategory(array)), animation: .default) + } + ) + } + } + + @ViewBuilder + private func coverContent(_ coverStore: Store) -> some View { + switch coverStore.destination { + case .todoEditor: + if let selectedCategory = coverStore.selectedTodoCategory { + TodoEditorView( + store: coordinator.makeTodoEditorStore(category: selectedCategory), + onCreateSuccess: { + store.send(.store(.setPresentation(.todoEditor, false))) + store.send(.view(.fetchData)) + } + ) + } + case .search: + SearchView(store: coordinator.makeSearchStore()) + } + } + @ViewBuilder private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { if isCompactLayout { @@ -292,72 +344,14 @@ struct HomeView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { - coordinator.viewModel.send(.deleteWebPage(item)) + store.send(.view(.deleteWebPage(item))) + presentDeleteWebPageToast(item.url.absoluteString) } label: { Label(String(localized: "common_delete"), systemImage: "trash") } } } - private var contentPicker: some View { - NavigationStack { - List { - Section { - if coordinator.viewModel.state.isPreferencesLoading { - LoadingView() - } else { - let preferences = coordinator.viewModel.state.preferences.filter(\.isVisible) - ForEach(preferences, id: \.id) { item in - Button { - DispatchQueue.main.async { - openTodoEditor(for: item.category) - } - } label: { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) - } - } - } - } header: { - Text("TODO") - .foregroundStyle(Color(.label)) - } - - Section { - Button { - DispatchQueue.main.async { - coordinator.viewModel.send(.setAlert(isPresented: true, type: .webPageInput)) - } - } label: { - labelImage( - text: "URL", - systemName: "globe", - imageColor: .blue - ) - } - } header: { - Text("Web Page") - .foregroundStyle(Color(.label)) - } - } - .navigationTitle(String(localized: "nav_home_content")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - coordinator.viewModel.send(.setPresentation(.contentPicker, false)) - } label: { - Image(systemName: "xmark") - .bold() - } - } - } - } - } - private func labelImage( text: String, systemName: String, @@ -381,16 +375,32 @@ struct HomeView: View { private func openTodoEditor(for todoCategory: TodoCategory) { if isiOSAppOnMac { - coordinator.viewModel.send(.setPresentation(.contentPicker, false)) + store.send(.store(.setPresentation(.contentPicker, false))) openWindow( id: TodoEditorWindowValue.sceneId, value: TodoEditorWindowValue(todoCategory: todoCategory, source: .home) ) } else { - coordinator.viewModel.send(.tapTodoCategory(todoCategory)) + store.send(.view(.tapTodoCategory(todoCategory))) } } + private func presentDeleteWebPageToast(_ urlString: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + font: .caption, + multilineTextAlignment: .center, + action: { + store.send(.view(.undoDeleteWebPage)) + }, + onDismiss: { + store.send(.view(.finishDeleteWebPageToast(urlString))) + } + ) + } + } enum HomeRoute: Hashable { diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 19e5ba68..afca82f3 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -14,7 +14,7 @@ import DevLogDomain @MainActor @Observable final class HomeViewCoordinator { - let viewModel: HomeViewModel + let store: StoreOf let router = NavigationRouter() private let container: DIContainer @ObservationIgnored @@ -26,25 +26,30 @@ final class HomeViewCoordinator { init(container: DIContainer) { self.container = container - self.viewModel = HomeViewModel( - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), - addWebPageUseCase: container.resolve(AddWebPageUseCase.self), - deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), - undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self) - ) + self.store = Store(initialState: HomeFeature.State()) { + HomeFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = container.resolve(FetchTodoCategoryPreferencesUseCase.self) + $0.homeUpdateTodoCategoryPreferencesUseCase = container.resolve( + UpdateTodoCategoryPreferencesUseCase.self + ) + $0.homeAddWebPageUseCase = container.resolve(AddWebPageUseCase.self) + $0.homeDeleteWebPageUseCase = container.resolve(DeleteWebPageUseCase.self) + $0.homeUndoDeleteWebPageUseCase = container.resolve(UndoDeleteWebPageUseCase.self) + $0.homeFetchTodosUseCase = container.resolve(FetchTodosUseCase.self) + $0.homeFetchWebPagesUseCase = container.resolve(FetchWebPagesUseCase.self) + $0.networkConnectivityUseCase = container.resolve(ObserveNetworkConnectivityUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + } + self.store.send(.view(.startObserving)) } func fetchData() { - viewModel.send(.fetchData) + store.send(.view(.fetchData)) } func refreshRecentTodos() { - viewModel.send(.refreshRecentTodos) + store.send(.view(.refreshRecentTodos)) } func bindTodoMutationEvent() { @@ -72,7 +77,7 @@ final class HomeViewCoordinator { .sink { [weak self] submit in guard case .create(let value) = submit, value.matchesCreate(source: .home) else { return } - self?.viewModel.send(.fetchData) + self?.store.send(.view(.fetchData)) } .store(in: &cancellables) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift deleted file mode 100644 index c5fcdb26..00000000 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift +++ /dev/null @@ -1,486 +0,0 @@ -// -// HomeViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/22/25. -// - -import Foundation -import Combine -import DevLogCore -import DevLogDomain - -@Observable -final class HomeViewModel: StorePattern { - struct State: Equatable { - var preferences: [TodoCategoryItem] = [] - var recentTodos: [RecentTodoItem] = [] - var webPages: [WebPageItem] = [] - var needsWebPageRefresh = false - var isNetworkConnected: Bool = true - var showContentPicker: Bool = false - var showTodoEditor: Bool = false - var showSearchView: Bool = false - var webPageURLInput: String = "https://" - var selectedTodoCategory: TodoCategory? - var reorderTodo: Bool = false - var isPreferencesLoading: Bool = false - var isRecentTodosLoading: Bool = false - var isWebPageLoading: Bool = false - var isAppending: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertType: AlertType? - var alertMessage: String = "" - } - - enum Action { - case fetchData - case refreshRecentTodos - case networkStatusChanged(Bool) - case setPresentation(Presentation, Bool) - case setAlert(isPresented: Bool, type: AlertType? = nil) - case refreshWebPages - case setLoading(LoadingTarget, Bool) - case setWebPageHidden(URL, Bool) - case handleWebPageDeleteFailure(URL) - case finishDeleteWebPageToast(String) - case tapTodoCategory(TodoCategory) - case orderTodoCategory([TodoCategoryItem]) - case setTodoCategory([TodoCategoryItem]) - case updateRecentTodos([RecentTodoItem]) - case updateWebPageURLInput(String) - case addWebPage - case deleteWebPage(WebPageItem) - case undoDeleteWebPage - case updateWebPages([WebPageItem]) - } - - enum SideEffect { - case addWebPage(String) - case deleteWebPage(WebPageItem) - case undoDeleteWebPage(String) - case fetchTodoCategoryPreferences - case updateTodoCategoryPreferences([TodoCategoryItem]) - case fetchRecentTodos - case fetchWebPages - case showModalAfterDelay(ModalType) - } - - enum AlertType { - case webPageInput - case invalidURL - case error - } - - enum ModalType { - case todoEditor - case urlInputAlert - } - - enum Presentation { - case reorderTodo - case todoEditor - case contentPicker - case searchView - } - - enum LoadingTarget: Hashable { - case preferences - case recentTodos - case webPage - case overlay - } - - private(set) var state = State() - private let fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase - private let updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase - private let addWebPageUseCase: AddWebPageUseCase - private let deleteWebPageUseCase: DeleteWebPageUseCase - private let undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase - private let fetchTodosUseCase: FetchTodosUseCase - private let fetchWebPagesUseCase: FetchWebPagesUseCase - private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - private let loadingState = LoadingState() - private var deletedWebPageURLString: String? - private var cancellables = Set() - - init( - fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, - updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase, - addWebPageUseCase: AddWebPageUseCase, - deleteWebPageUseCase: DeleteWebPageUseCase, - undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase, - fetchTodosUseCase: FetchTodosUseCase, - fetchWebPagesUseCase: FetchWebPagesUseCase, - networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - ) { - self.fetchPreferencesUseCase = fetchPreferencesUseCase - self.updatePreferencesUseCase = updatePreferencesUseCase - self.addWebPageUseCase = addWebPageUseCase - self.deleteWebPageUseCase = deleteWebPageUseCase - self.undoDeleteWebPageUseCase = undoDeleteWebPageUseCase - self.fetchTodosUseCase = fetchTodosUseCase - self.fetchWebPagesUseCase = fetchWebPagesUseCase - self.networkConnectivityUseCase = networkConnectivityUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - - setupNetworkObserving() - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .networkStatusChanged(let isConnected): - state.isNetworkConnected = isConnected - case .fetchData, .refreshRecentTodos, .setPresentation, .setAlert, .refreshWebPages, - .tapTodoCategory, .orderTodoCategory, .updateWebPageURLInput, - .addWebPage, .deleteWebPage, .undoDeleteWebPage, .finishDeleteWebPageToast: - effects = reduceByView(action, state: &state) - - case .setLoading, .setWebPageHidden, .handleWebPageDeleteFailure, .setTodoCategory, - .updateRecentTodos, .updateWebPages: - effects = reduceByRun(action, state: &state) - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchTodoCategoryPreferences: - beginLoading(for: .preferences, mode: .immediate) - Task { - do { - defer { endLoading(for: .preferences, mode: .immediate) } - let preferences = try await fetchPreferencesUseCase.execute() - send(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:)))) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .updateTodoCategoryPreferences(let items): - Task { - do { - try await updatePreferencesUseCase.execute(items.map(\.preference)) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .fetchRecentTodos: - beginLoading(for: .recentTodos, mode: .immediate) - Task { - do { - defer { endLoading(for: .recentTodos, mode: .immediate) } - let page = try await fetchRecentTodos() - let items = page.items - .filter { $0.createdAt != $0.updatedAt } - .prefix(5) - .compactMap { RecentTodoItem(from: $0) } - send(.updateRecentTodos(items)) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .addWebPage(let urlString): - beginLoading(for: .overlay, mode: .delayed) - Task { - do { - defer { endLoading(for: .overlay, mode: .delayed) } - try await addWebPageUseCase.execute(urlString) - trackAnalyticsEventUseCase.execute(.webPageCreate) - let pages = try await fetchWebPagesUseCase.execute("") - send(.updateWebPages(pages.map { WebPageItem(from: $0) })) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .deleteWebPage(let page): - Task { - do { - try await deleteWebPageUseCase.execute(page.url.absoluteString) - } catch { - send(.handleWebPageDeleteFailure(page.id)) - send(.setAlert(isPresented: true, type: .error)) - } - } - case .undoDeleteWebPage(let urlString): - Task { - do { - try await undoDeleteWebPageUseCase.execute(urlString) - try await addWebPageUseCase.execute(urlString) - } catch { - if let webPageURL = URL(string: urlString) { - send(.setWebPageHidden(webPageURL, true)) - } - send(.setAlert(isPresented: true, type: .error)) - } - } - case .fetchWebPages: - beginLoading(for: .webPage, mode: .immediate) - Task { - do { - defer { endLoading(for: .webPage, mode: .immediate) } - let pages = try await fetchWebPagesUseCase.execute("") - send(.updateWebPages(pages.map { WebPageItem(from: $0) })) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } - case .showModalAfterDelay(let type): - Task { - try await Task.sleep(for: .seconds(0.1)) - switch type { - case .todoEditor: - send(.setPresentation(.todoEditor, true)) - case .urlInputAlert: - send(.setAlert(isPresented: true, type: .webPageInput)) - } - } - } - } -} - -// MARK: - Reduce Methods -private extension HomeViewModel { - // swiftlint:disable cyclomatic_complexity - func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .fetchData: - return [.fetchTodoCategoryPreferences, .fetchRecentTodos, .fetchWebPages] - case .refreshRecentTodos: - return [.fetchRecentTodos] - case .refreshWebPages: - return [.fetchWebPages] - case .setPresentation(let presentation, let isPresented): - setPresentation(&state, presentation: presentation, isPresented: isPresented) - case .setAlert(let presented, let type): - if presented && type == .webPageInput && state.showContentPicker { - state.showContentPicker = false - return [.showModalAfterDelay(.urlInputAlert)] - } - setAlert(&state, isPresented: presented, type: type) - case .tapTodoCategory(let category): - state.selectedTodoCategory = category - state.showContentPicker = false - return [.showModalAfterDelay(.todoEditor)] - case .orderTodoCategory(let preferences): - state.preferences = preferences - state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) - return [.updateTodoCategoryPreferences(preferences)] - case .updateWebPageURLInput(let text): - state.webPageURLInput = text - case .addWebPage: - guard let normalizedURL = normalizedWebPageURL(state.webPageURLInput) else { - setAlert(&state, isPresented: true, type: .invalidURL) - return [] - } - setAlert(&state, isPresented: false, type: nil) - return [.addWebPage(normalizedURL)] - case .deleteWebPage(let page): - if let index = state.webPages.firstIndex(where: { $0.id == page.id }) { - let urlString = page.url.absoluteString - deletedWebPageURLString = urlString - state.webPages[index].isHidden = true - presentDeleteWebPageToast(urlString) - return [.deleteWebPage(page)] - } - case .undoDeleteWebPage: - guard let deletedWebPageURLString else { return [] } - if let index = state.webPages.firstIndex(where: { - $0.url.absoluteString == deletedWebPageURLString - }) { - state.webPages[index].isHidden = false - } - self.deletedWebPageURLString = nil - return [.undoDeleteWebPage(deletedWebPageURLString)] - case .finishDeleteWebPageToast(let urlString): - state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden } - if deletedWebPageURLString == urlString { - deletedWebPageURLString = nil - } - default: - break - } - return [] - } - // swiftlint:enable cyclomatic_complexity - - func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .setLoading(let loadingTarget, let isLoading): - setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) - case .setWebPageHidden(let webPageURL, let isHidden): - if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { - state.webPages[index].isHidden = isHidden - } - case .handleWebPageDeleteFailure(let webPageURL): - if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { - state.webPages[index].isHidden = false - } else { - state.needsWebPageRefresh = true - } - case .setTodoCategory(let preferences): - state.preferences = preferences - state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) - case .updateRecentTodos(let todos): - state.recentTodos = todos - case .updateWebPages(let pages): - state.webPages = pages - state.needsWebPageRefresh = false - default: - break - } - return [] - } -} - -// MARK: - Helper Methods -private extension HomeViewModel { - func setPresentation( - _ state: inout State, - presentation: Presentation, - isPresented: Bool - ) { - switch presentation { - case .reorderTodo: - state.reorderTodo = isPresented - case .todoEditor: - state.showTodoEditor = isPresented - if !isPresented { state.selectedTodoCategory = nil } - case .contentPicker: - state.showContentPicker = isPresented - case .searchView: - state.showSearchView = isPresented - } - } - - func setAlert( - _ state: inout State, - isPresented: Bool, - type: AlertType? - ) { - switch type { - case .webPageInput: - state.alertTitle = String(localized: "home_webpage_input_title") - state.alertMessage = String(localized: "home_webpage_input_message") - state.webPageURLInput = "https://" - case .invalidURL: - state.alertTitle = String(localized: "home_invalid_url_title") - state.alertMessage = String(localized: "home_invalid_url_message") - case .error: - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - case .none: - state.alertTitle = "" - state.alertMessage = "" - } - state.showAlert = isPresented - state.alertType = type - } - - func presentDeleteWebPageToast(_ urlString: String) { - ToastPresenter.present( - message: String(localized: "common_undo"), - systemImage: "arrow.uturn.left", - duration: 5, - font: .caption, - multilineTextAlignment: .center, - action: { [weak self] in - self?.send(.undoDeleteWebPage) - }, - onDismiss: { [weak self] in - self?.send(.finishDeleteWebPageToast(urlString)) - } - ) - } - - func setLoading( - _ state: inout State, - loadingTarget: LoadingTarget, - isLoading: Bool - ) { - switch loadingTarget { - case .preferences: - state.isPreferencesLoading = isLoading - case .recentTodos: - state.isRecentTodosLoading = isLoading - case .webPage: - state.isWebPageLoading = isLoading - case .overlay: - state.isAppending = isLoading - } - } - - func syncRecentTodos( - _ recentTodos: [RecentTodoItem], - preferences: [TodoCategoryItem] - ) -> [RecentTodoItem] { - recentTodos.map { recentTodo in - guard let item = preferences.first(where: { - $0.category.storageValue == recentTodo.category.storageValue - }) else { - return recentTodo - } - - var recentTodo = recentTodo - recentTodo.category = item.category - return recentTodo - } - } - - func normalizedWebPageURL(_ input: String) -> String? { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed == "https://" || trimmed == "http://" { - return nil - } - if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") { - return trimmed - } - return "https://" + trimmed - } - - func fetchRecentTodos() async throws -> TodoPage { - try await fetchTodosUseCase.execute( - TodoQuery( - sortTarget: .updatedAt, - sortOrder: .latest, - pageSize: 100 - ), - 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)) - } - } - - func setupNetworkObserving() { - networkConnectivityUseCase.observe() - .receive(on: DispatchQueue.main) - .sink { [weak self] isConnected in - self?.send(.networkStatusChanged(isConnected)) - } - .store(in: &cancellables) - } -} diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index 7c5f77e9..d5af1df8 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -63,20 +63,24 @@ struct TodoListFeature { case alert(PresentationAction) case fullScreenCover(PresentationAction) case binding(BindingAction) - case refresh - case setFullScreenCover(FullScreenCoverState?) - case swipeTodo(TodoListItem) - case resetFilters - case finishDeleteToast(String) - case tapToggleCompleted(TodoListItem) - case tapTogglePinned(TodoListItem) - case undoDelete - case onAppear - case loadNextPage + case view(ViewAction) case store(StoreAction) case loading(LoadingFeature.Action) + enum ViewAction: Equatable { + case refresh + case swipeTodo(TodoListItem) + case resetFilters + case finishDeleteToast(String) + case tapToggleCompleted(TodoListItem) + case tapTogglePinned(TodoListItem) + case undoDelete + case onAppear + case loadNextPage + } + enum StoreAction: Equatable { + case setFullScreenCover(FullScreenCoverState?) case setAlert(Bool) case applySearchQuery(String) case fetchSearchResults([TodoListItem]) @@ -111,7 +115,36 @@ struct TodoListFeature { } BindingReducer() Reduce { state, action in - reduce(action, state: &state) + switch action { + case .alert: + break + case .fullScreenCover(.dismiss): + state.fullScreenCover = nil + case .fullScreenCover: + break + case .binding(\.searchText): + return setSearchTextEffect(state: &state) + case .binding(\.isSearching): + guard !state.isSearching else { break } + state.searchText = "" + state.searchResults = [] + state.showAllSearchResults = false + return cancelSearchEffect() + case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.query.isPinned), + .binding(\.query.completionFilter): + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .binding: + break + case .view(let action): + return reduce(action, state: &state) + case .store(let action): + return reduce(action, state: &state) + case .loading: + break + } + + return .none } .ifLet(\.$alert, action: \.alert) } @@ -165,84 +198,6 @@ private enum TodoListUndoDeleteTodoUseCaseKey: DependencyKey { } private extension TodoListFeature { - func reduce(_ action: Action, state: inout State) -> Effect { - switch action { - case .alert: - break - case .fullScreenCover(.dismiss): - state.fullScreenCover = nil - case .fullScreenCover: - break - case .binding(\.searchText): - return setSearchTextEffect(state: &state) - case .binding(\.isSearching): - guard !state.isSearching else { break } - state.searchText = "" - state.searchResults = [] - state.showAllSearchResults = false - return cancelSearchEffect() - case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.query.isPinned), - .binding(\.query.completionFilter): - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) - case .binding: - break - case .refresh, .onAppear: - return fetchEffect(query: state.query, cursor: nil) - case .store(.setAlert(let value)): - Self.setAlert(&state, isPresented: value) - case .setFullScreenCover(let cover): - state.fullScreenCover = cover - case .swipeTodo(let todo): - return swipeTodoEffect(todo, state: &state) - case .resetFilters: - state.query = TodoQuery(categoryId: state.category.storageValue) - state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) - case .finishDeleteToast(let todoId): - state.todos.removeAll { $0.id == todoId && $0.isHidden } - state.searchResults.removeAll { $0.id == todoId && $0.isHidden } - if state.undoTodoId == todoId { - state.undoTodoId = nil - } - case .tapToggleCompleted(let todo): - return toggleCompletedEffect(todo) - case .tapTogglePinned(let todo): - return togglePinnedEffect(todo) - case .undoDelete: - guard let undoTodoId = state.undoTodoId else { return .none } - Self.setTodoHidden(&state, todoId: undoTodoId, isHidden: false) - state.undoTodoId = nil - return undoDeleteEffect(undoTodoId) - case .loadNextPage: - guard state.hasMore, !state.isLoading else { return .none } - return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false) - case .store(.applySearchQuery(let query)): - return applySearchQueryEffect(query, state: &state) - case .store(.fetchSearchResults(let items)): - state.searchResults = items - case .store(.didToggleCompleted(let todo)), .store(.didTogglePinned(let todo)): - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { - state.todos[index] = todo - } - case .store(.setTodoHidden(let todoId, let isHidden)): - Self.setTodoHidden(&state, todoId: todoId, isHidden: isHidden) - case .store(.appendTodos(let todos, let nextCursor)): - state.todos.append(contentsOf: todos) - state.nextCursor = nextCursor - case .store(.resetPagination): - state.todos = [] - state.nextCursor = nil - state.hasMore = false - case .store(.setHasMore(let value)): - state.hasMore = value - case .loading: - break - } - - return .none - } - func fetchEffect( query: TodoQuery, cursor: TodoCursor?, @@ -316,4 +271,73 @@ private extension TodoListFeature { .cancellable(id: CancelID.debounce, cancelInFlight: true) ) } + + func reduce( + _ action: Action.ViewAction, + state: inout State + ) -> Effect { + switch action { + case .refresh, .onAppear: + return fetchEffect(query: state.query, cursor: nil) + case .swipeTodo(let todo): + return swipeTodoEffect(todo, state: &state) + case .resetFilters: + state.query = TodoQuery(categoryId: state.category.storageValue) + state.nextCursor = nil + return fetchEffect(query: state.query, cursor: nil) + case .finishDeleteToast(let todoId): + state.todos.removeAll { $0.id == todoId && $0.isHidden } + state.searchResults.removeAll { $0.id == todoId && $0.isHidden } + if state.undoTodoId == todoId { + state.undoTodoId = nil + } + case .tapToggleCompleted(let todo): + return toggleCompletedEffect(todo) + case .tapTogglePinned(let todo): + return togglePinnedEffect(todo) + case .undoDelete: + guard let undoTodoId = state.undoTodoId else { return .none } + Self.setTodoHidden(&state, todoId: undoTodoId, isHidden: false) + state.undoTodoId = nil + return undoDeleteEffect(undoTodoId) + case .loadNextPage: + guard state.hasMore, !state.isLoading else { return .none } + return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false) + } + + return .none + } + + func reduce( + _ action: Action.StoreAction, + state: inout State + ) -> Effect { + switch action { + case .setFullScreenCover(let cover): + state.fullScreenCover = cover + case .setAlert(let value): + Self.setAlert(&state, isPresented: value) + case .applySearchQuery(let query): + return applySearchQueryEffect(query, state: &state) + case .fetchSearchResults(let items): + state.searchResults = items + case .didToggleCompleted(let todo), .didTogglePinned(let todo): + if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { + state.todos[index] = todo + } + case .setTodoHidden(let todoId, let isHidden): + Self.setTodoHidden(&state, todoId: todoId, isHidden: isHidden) + case .appendTodos(let todos, let nextCursor): + state.todos.append(contentsOf: todos) + state.nextCursor = nextCursor + case .resetPagination: + state.todos = [] + state.nextCursor = nil + state.hasMore = false + case .setHasMore(let value): + state.hasMore = value + } + + return .none + } } diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index f6d767b4..9e5f9bf2 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -80,7 +80,7 @@ struct TodoListView: View { } .background(NavigationBarConfigurator()) .background(Color(.systemGroupedBackground)) - .task { store.send(.onAppear) } + .task { store.send(.view(.onAppear)) } } @ViewBuilder @@ -118,18 +118,18 @@ struct TodoListView: View { .onAppear { let lastID = visibleTodos.last?.id if todo.id == lastID, store.state.hasMore { - store.send(.loadNextPage) + store.send(.view(.loadNextPage)) } } .swipeActions(edge: .leading) { Button(action: { - store.send(.tapTogglePinned(todo)) + store.send(.view(.tapTogglePinned(todo))) }) { Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") } .tint(Color.orange) Button { - store.send(.tapToggleCompleted(todo)) + store.send(.view(.tapToggleCompleted(todo))) } label: { Image(systemName: todo.isCompleted ? "arrow.uturn.backward" : "checkmark") } @@ -137,7 +137,7 @@ struct TodoListView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive, action: { - store.send(.swipeTodo(todo)) + store.send(.view(.swipeTodo(todo))) presentDeleteTodoToast(todo.id) }) { Image(systemName: "trash") @@ -171,7 +171,7 @@ struct TodoListView: View { } .offset(y: headerOffset) } - .refreshable { store.send(.refresh) } + .refreshable { store.send(.view(.refresh)) } .scrollDisabled(visibleTodos.isEmpty || store.state.isLoading) if store.state.isLoading { @@ -212,8 +212,8 @@ struct TodoListView: View { $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) }, onCreateSuccess: { - store.send(.setFullScreenCover(nil)) - store.send(.refresh) + store.send(.store(.setFullScreenCover(nil))) + store.send(.view(.refresh)) } ) } @@ -226,7 +226,7 @@ struct TodoListView: View { value: TodoEditorWindowValue(todoCategory: store.category, source: .list) ) } else { - store.send(.setFullScreenCover(.editor)) + store.send(.store(.setFullScreenCover(.editor))) } } @@ -236,10 +236,10 @@ struct TodoListView: View { systemImage: "arrow.uturn.left", duration: 5, action: { - store.send(.undoDelete) + store.send(.view(.undoDelete)) }, onDismiss: { - store.send(.finishDeleteToast(todoId)) + store.send(.view(.finishDeleteToast(todoId))) } ) } @@ -310,7 +310,7 @@ struct TodoListView: View { ) ) Button(role: .destructive) { - store.send(.resetFilters) + store.send(.view(.resetFilters)) } label: { Text(String(localized: "todo_list_clear_filters")) } diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index d37d0e06..911f3340 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -53,20 +53,24 @@ struct PushNotificationListFeature { case alert(PresentationAction) case sheet(PresentationAction) case binding(BindingAction) - case fetchNotifications - case loadNextPage - case deleteNotification(PushNotificationItem) - case toggleRead(PushNotificationItem) - case undoDelete - case finishDeleteToast(String) - case toggleSortOption - case toggleUnreadOnly - case resetFilters - case selectNotification(String?) - case syncSheetPresentation(isCompactLayout: Bool) + case view(ViewAction) case store(StoreAction) case loading(LoadingFeature.Action) + enum ViewAction: Equatable { + case fetchNotifications + case loadNextPage + case deleteNotification(PushNotificationItem) + case toggleRead(PushNotificationItem) + case undoDelete + case finishDeleteToast(String) + case toggleSortOption + case toggleUnreadOnly + case resetFilters + case selectNotification(String?) + case syncSheetPresentation(isCompactLayout: Bool) + } + enum Sheet: Equatable { case tapCloseButton } @@ -101,7 +105,29 @@ struct PushNotificationListFeature { } BindingReducer() Reduce { state, action in - reduce(action, state: &state) + switch action { + case .alert: + break + case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): + state.sheet = nil + state.selectedNotificationId = nil + state.selectedTodoId = nil + case .sheet: + break + case .binding(\.query.timeFilter): + state.nextCursor = nil + return refreshForQueryChangeEffect(query: state.query) + case .binding: + break + case .view(let action): + return reduce(action, state: &state) + case .store(let action): + return reduce(action, state: &state) + case .loading: + break + } + + return .none } .ifLet(\.$alert, action: \.alert) .ifLet(\.$sheet, action: \.sheet) { @@ -121,23 +147,10 @@ private struct PushNotificationListSheetFeature: Reducer { private extension PushNotificationListFeature { func reduce( - _ action: Action, + _ action: Action.ViewAction, state: inout State ) -> Effect { switch action { - case .alert: - break - case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): - state.sheet = nil - state.selectedNotificationId = nil - state.selectedTodoId = nil - case .sheet: - break - case .binding(\.query.timeFilter): - state.nextCursor = nil - return refreshForQueryChangeEffect(query: state.query) - case .binding: - break case .fetchNotifications: state.nextCursor = nil return fetchNotificationsEffect(query: state.query, cursor: nil, existingCount: 0) @@ -170,32 +183,6 @@ private extension PushNotificationListFeature { if state.undoNotificationId == notificationId { state.undoNotificationId = nil } - case .store(.setAlert): - state.alert = Self.alertState() - case .store(.appendNotifications(let notifications, let nextCursor)): - state.notifications.append(contentsOf: Self.mergedHiddenNotifications( - currentNotifications: state.notifications, - incomingNotifications: notifications - )) - state.nextCursor = nextCursor - case .store(.resetPagination): - state.notifications = [] - state.nextCursor = nil - case .store(.setHasMore(let value)): - state.hasMore = value - case .store(.syncNotifications(let notifications, let nextCursor, let hasMore)): - state.notifications = Self.mergedHiddenNotifications( - currentNotifications: state.notifications, - incomingNotifications: notifications - ) - state.nextCursor = nextCursor - state.hasMore = hasMore - case .store(.setNotificationHidden(let notificationId, let isHidden)): - Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) - case .store(.setNotificationRead(let notificationId, let isRead)): - if let index = state.notifications.firstIndex(where: { $0.id == notificationId }) { - state.notifications[index].isRead = isRead - } case .toggleSortOption: state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest state.nextCursor = nil @@ -229,10 +216,44 @@ private extension PushNotificationListFeature { } else { state.sheet = nil } - case .store(.observeNotifications(let query, let limit)): + } + + return .none + } + + func reduce( + _ action: Action.StoreAction, + state: inout State + ) -> Effect { + switch action { + case .setAlert: + state.alert = Self.alertState() + case .appendNotifications(let notifications, let nextCursor): + state.notifications.append(contentsOf: Self.mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + )) + state.nextCursor = nextCursor + case .resetPagination: + state.notifications = [] + state.nextCursor = nil + case .setHasMore(let value): + state.hasMore = value + case .syncNotifications(let notifications, let nextCursor, let hasMore): + state.notifications = Self.mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + ) + state.nextCursor = nextCursor + state.hasMore = hasMore + case .setNotificationHidden(let notificationId, let isHidden): + Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) + case .setNotificationRead(let notificationId, let isRead): + if let index = state.notifications.firstIndex(where: { $0.id == notificationId }) { + state.notifications[index].isRead = isRead + } + case .observeNotifications(let query, let limit): return observeNotificationsEffect(query: query, limit: limit) - case .loading: - break } return .none diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 27f39efc..d6810f7c 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -38,7 +38,7 @@ struct PushNotificationListView: View { headerOffset = max(0, -offset) } .safeAreaInset(edge: .top) { safeAreaHeader } - .refreshable { store.send(.fetchNotifications) } + .refreshable { store.send(.view(.fetchNotifications)) } .navigationTitle(String(localized: "nav_push_notifications")) .listStyle(.plain) } @@ -47,10 +47,10 @@ struct PushNotificationListView: View { sheetContent(store) } .task(id: isCompactLayout) { - store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) } .onChange(of: store.selectedTodoId?.id, initial: true) { - store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) } .overlay { if store.isLoading { @@ -96,7 +96,7 @@ struct PushNotificationListView: View { ) -> some View { if isCompactLayout { Button { - store.send(.selectNotification(notification.id)) + store.send(.view(.selectNotification(notification.id))) } label: { notificationRowContent(notification, index: index, notifications: notifications) } @@ -104,11 +104,11 @@ struct PushNotificationListView: View { } else { notificationRowContent(notification, index: index, notifications: notifications) .onTapGesture { - store.send(.selectNotification(notification.id)) + store.send(.view(.selectNotification(notification.id))) } .accessibilityAddTraits(.isButton) .accessibilityAction { - store.send(.selectNotification(notification.id)) + store.send(.view(.selectNotification(notification.id))) } } } @@ -125,7 +125,7 @@ struct PushNotificationListView: View { .onAppear { let lastId = notifications.last?.id if notification.id == lastId, store.hasMore { - store.send(.loadNextPage) + store.send(.view(.loadNextPage)) } } .overlay(alignment: .top) { @@ -168,7 +168,7 @@ struct PushNotificationListView: View { ) ) Button(role: .destructive) { - store.send(.resetFilters) + store.send(.view(.resetFilters)) } label: { Text(String(localized: "push_clear_all_filters")) } @@ -183,7 +183,7 @@ struct PushNotificationListView: View { Button { DispatchQueue.main.async { - store.send(.toggleSortOption) + store.send(.view(.toggleSortOption)) } } label: { let condition = store.query.sortOrder == .oldest @@ -218,7 +218,7 @@ struct PushNotificationListView: View { Button { DispatchQueue.main.async { - store.send(.toggleUnreadOnly) + store.send(.view(.toggleUnreadOnly)) } } label: { let condition = store.query.unreadOnly @@ -306,7 +306,7 @@ struct PushNotificationListView: View { } .swipeActions(edge: .leading) { Button { - store.send(.toggleRead(item)) + store.send(.view(.toggleRead(item))) } label: { Image(systemName: "checkmark.circle\(item.isRead ? ".badge.xmark" : "")") .tint(.blue) @@ -316,7 +316,7 @@ struct PushNotificationListView: View { Button( role: .destructive, action: { - store.send(.deleteNotification(item)) + store.send(.view(.deleteNotification(item))) presentDeleteNotificationToast(item.id) } ) { @@ -391,10 +391,10 @@ struct PushNotificationListView: View { multilineTextAlignment: .center, lineLimit: 3, action: { - store.send(.undoDelete) + store.send(.view(.undoDelete)) }, onDismiss: { - store.send(.finishDeleteToast(notificationId)) + store.send(.view(.finishDeleteToast(notificationId))) } ) } diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift index 28e34404..dec844f0 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift @@ -38,7 +38,7 @@ final class PushNotificationListViewCoordinator { } func fetchData() { - store.send(.fetchNotifications) + store.send(.view(.fetchNotifications)) } func makeTodoDetailStore(todoId: String) -> StoreOf { diff --git a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift index 22576937..1a7343f6 100644 --- a/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift +++ b/Application/DevLogPresentation/Sources/WindowGroup/TodoWindowCoordinator.swift @@ -84,7 +84,7 @@ final class TodoWindowCoordinator { case .create(let value): if let listStore, value.matchesCreate(category: listStore.category, source: .list) { - listStore.send(.refresh) + listStore.send(.view(.refresh)) } case .update(let value, let todo): if let detailStore, diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift new file mode 100644 index 00000000..96d21f2b --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift @@ -0,0 +1,232 @@ +// +// HomeFeatureTestAssertions.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +func verifyHomeFetchData( + adapter: HomeStoreTestAdapter, + fetchTodosUseCaseSpy: FetchTodosUseCaseSpy, + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy +) async throws { + await adapter.fetchData() + + await waitUntil { + adapter.preferences.count == 2 + && adapter.recentTodos.count == 2 + && adapter.webPages.count == 1 + } + + #expect(adapter.preferences.map(\.id) == ["feature", "custom"]) + #expect(adapter.recentTodos.map(\.id) == ["todo-1", "todo-2"]) + #expect(adapter.webPages.map(\.url.absoluteString) == ["https://openai.com"]) + #expect(fetchTodosUseCaseSpy.queries.count == 1) + #expect(fetchTodosUseCaseSpy.queries.first?.sortTarget == .updatedAt) + #expect(fetchTodosUseCaseSpy.queries.first?.sortOrder == .latest) + #expect(fetchTodosUseCaseSpy.queries.first?.pageSize == 100) + #expect(fetchWebPagesUseCaseSpy.calledQueries == [""]) +} + +@MainActor +func verifyHomeWebPageInputAlert( + adapter: HomeStoreTestAdapter +) async throws { + await adapter.setPresentation(.contentPicker, true) + + #expect(adapter.showContentPicker) + + await adapter.openWebPageInput() + + #expect(adapter.showContentPicker) + #expect(!adapter.showAlert) + + await waitUntil { + adapter.showWebPageInputNavigation + } + + #expect(adapter.showWebPageInputNavigation) + #expect(adapter.webPageURLInput == "https://") +} + +@MainActor +func verifyHomeTapTodoCategory( + adapter: HomeStoreTestAdapter +) async throws { + await adapter.setPresentation(.contentPicker, true) + await adapter.tapTodoCategory(.system(.feature)) + + #expect(!adapter.showContentPicker) + #expect(adapter.showTodoEditor) +} + +@MainActor +func verifyHomeOrderTodoCategory( + adapter: HomeStoreTestAdapter, + updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy +) async throws { + await adapter.fetchData() + + let updatedCategory = TodoCategoryItem( + from: .user( + UserTodoCategory( + id: "custom", + name: "Updated", + colorHex: "#222222" + ) + ) + ) + let items = [ + updatedCategory, + TodoCategoryItem(from: .system(.feature)) + ] + + await adapter.orderTodoCategory(items) + + #expect(adapter.preferences == items) + #expect(adapter.recentTodos.last?.category == updatedCategory.category) + #expect(updatePreferencesUseCaseSpy.updates == [items.map(\.preference)]) +} + +@MainActor +func verifyHomeAddWebPage( + adapter: HomeStoreTestAdapter, + addWebPageUseCaseSpy: AddWebPageUseCaseSpy, + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy +) async throws { + await adapter.updateWebPageURLInput("openai.com") + await adapter.addWebPage() + + await waitUntil { + addWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + && adapter.webPages.count == 2 + } + + #expect(addWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) + #expect(fetchWebPagesUseCaseSpy.calledQueries == [""]) + #expect(trackAnalyticsEventUseCaseSpy.events.count == 1) + #expect(adapter.webPages.map(\.url.absoluteString) == [ + "https://openai.com", + "https://developer.apple.com" + ]) + #expect(!adapter.showAlert) +} + +struct HomeFetchDataContext { + let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy + let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy + let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy +} + +func makeHomeFetchDataContext() -> HomeFetchDataContext { + let fetchPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() + fetchPreferencesUseCaseSpy.todoCategoryPreferences = [ + TodoCategoryPreference(category: .system(.feature), isVisible: true), + TodoCategoryPreference( + category: .user( + UserTodoCategory( + id: "custom", + name: "Custom", + colorHex: "#111111" + ) + ), + isVisible: true + ) + ] + + let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() + let createdAt = Date(timeIntervalSince1970: 0) + fetchTodosUseCaseSpy.todoPage = TodoPage( + items: [ + makeHomeTodo(id: "todo-1", category: .system(.feature), number: 1), + makeHomeTodo( + id: "todo-2", + category: .user( + UserTodoCategory( + id: "custom", + name: "Custom", + colorHex: "#111111" + ) + ), + number: 2 + ), + makeHomeTodo( + id: "todo-ignored", + number: 3, + createdAt: createdAt, + updatedAt: createdAt + ) + ], + nextCursor: nil + ) + + let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( + webPages: [makeHomeWebPage()] + ) + + return HomeFetchDataContext( + fetchPreferencesUseCaseSpy: fetchPreferencesUseCaseSpy, + fetchTodosUseCaseSpy: fetchTodosUseCaseSpy, + fetchWebPagesUseCaseSpy: fetchWebPagesUseCaseSpy + ) +} + +struct HomeOrderContext { + let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy + let updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy + let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy +} + +func makeHomeOrderContext() -> HomeOrderContext { + let fetchContext = makeHomeFetchDataContext() + return HomeOrderContext( + fetchPreferencesUseCaseSpy: fetchContext.fetchPreferencesUseCaseSpy, + updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy(), + fetchTodosUseCaseSpy: fetchContext.fetchTodosUseCaseSpy + ) +} + +struct HomeAddWebPageContext { + let addWebPageUseCaseSpy: AddWebPageUseCaseSpy + let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy + let trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy +} + +func makeHomeAddWebPageContext() -> HomeAddWebPageContext { + HomeAddWebPageContext( + addWebPageUseCaseSpy: AddWebPageUseCaseSpy(), + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy( + webPages: [ + makeHomeWebPage(), + makeHomeWebPage( + title: "Apple", + urlString: "https://developer.apple.com" + ) + ] + ), + trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy() + ) +} + +struct HomeDeleteContext { + let addWebPageUseCaseSpy: AddWebPageUseCaseSpy + let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy + let deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy + let undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy +} + +func makeHomeDeleteContext() -> HomeDeleteContext { + HomeDeleteContext( + addWebPageUseCaseSpy: AddWebPageUseCaseSpy(), + fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy(webPages: [makeHomeWebPage()]), + deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy(), + undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy() + ) +} diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift new file mode 100644 index 00000000..8a3367f8 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -0,0 +1,198 @@ +// +// HomeFeatureTestSupport.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +@testable import DevLogPresentation +import Foundation + +@MainActor +struct HomeStoreTestAdapter { + private let store: TestStoreOf + private let clock: TestClock + + var preferences: [TodoCategoryItem] { store.state.preferences } + var recentTodos: [RecentTodoItem] { store.state.recentTodos } + var webPages: [WebPageItem] { store.state.webPages } + var isNetworkConnected: Bool { store.state.isNetworkConnected } + var showContentPicker: Bool { store.state.showContentPicker } + var showWebPageInputNavigation: Bool { + store.state.sheet?.contentPickerState?.webPageInput != nil + } + var showTodoEditor: Bool { store.state.showTodoEditor } + var showAlert: Bool { store.state.alert != nil } + var alertType: HomeFeature.AlertType? { + guard let title = store.state.alert?.title else { return nil } + if title == TextState(String(localized: "home_invalid_url_title")) { + return .invalidURL + } + if title == TextState(String(localized: "common_error_title")) { + return .error + } + return nil + } + var alertTitle: String { + if let title = store.state.alert?.title { + return String(state: title) + } + return "" + } + var webPageURLInput: String { store.state.webPageURLInput } + + init( + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), + updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), + addWebPageUseCase: AddWebPageUseCase = AddWebPageUseCaseSpy(), + deleteWebPageUseCase: DeleteWebPageUseCase = DeleteWebPageUseCaseSpy(), + undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase = UndoDeleteWebPageUseCaseSpy(), + fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), + fetchWebPagesUseCase: FetchWebPagesUseCase = FetchWebPagesUseCaseSpy(webPages: []), + networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = HomeTrackAnalyticsEventUseCaseSpy(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + let clock = TestClock() + self.clock = clock + store = TestStore(initialState: HomeFeature.State()) { + HomeFeature() + } withDependencies: { + $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase + $0.homeUpdateTodoCategoryPreferencesUseCase = updatePreferencesUseCase + $0.homeAddWebPageUseCase = addWebPageUseCase + $0.homeDeleteWebPageUseCase = deleteWebPageUseCase + $0.homeUndoDeleteWebPageUseCase = undoDeleteWebPageUseCase + $0.homeFetchTodosUseCase = fetchTodosUseCase + $0.homeFetchWebPagesUseCase = fetchWebPagesUseCase + $0.networkConnectivityUseCase = networkConnectivityUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.continuousClock = clock + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func startObserving() async { + await store.send(.view(.startObserving)) + await drainReceivedActions() + } + + func fetchData() async { + await store.send(.view(.fetchData)) + await drainReceivedActions() + } + + func openWebPageInput() async { + await store.send(.sheet(.presented(.contentPicker(.tapWebPageInput)))) + await drainReceivedActions() + } + + func setPresentation(_ presentation: HomeFeature.Presentation, _ isPresented: Bool) async { + await store.send(.store(.setPresentation(presentation, isPresented))) + } + + func setAlert(isPresented: Bool, type: HomeFeature.AlertType?) async { + await store.send(.store(.setAlert(isPresented: isPresented, type: type))) + await drainReceivedActions() + } + + func tapTodoCategory(_ category: TodoCategory) async { + await store.send(.view(.tapTodoCategory(category))) + await clock.advance(by: .seconds(1)) + await settle() + } + + func orderTodoCategory(_ items: [TodoCategoryItem]) async { + await store.send(.view(.orderTodoCategory(items))) + await drainReceivedActions() + } + + func updateWebPageURLInput(_ input: String) async { + await store.send(.view(.updateWebPageURLInput(input))) + } + + func addWebPage() async { + await store.send(.view(.addWebPage)) + await drainReceivedActions() + } + + func deleteWebPage(_ page: WebPageItem) async { + await store.send(.view(.deleteWebPage(page))) + await drainReceivedActions() + } + + func undoDeleteWebPage() async { + await store.send(.view(.undoDeleteWebPage)) + await drainReceivedActions() + } + + func finishDeleteWebPageToast(_ urlString: String) async { + await store.send(.view(.finishDeleteWebPageToast(urlString))) + } + + func drainReceivedActions() async { + for _ in 0..<12 { + await store.skipReceivedActions(strict: false) + } + } + + func settle() async { + await Task.yield() + await drainReceivedActions() + } +} + +final class HomeTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + private(set) var events = [AnalyticsEvent]() + + func execute(_ event: AnalyticsEvent) { + events.append(event) + } +} + +func makeHomeTodo( + id: String, + category: TodoCategory = .system(.feature), + number: Int = 1, + title: String = "Todo", + isPinned: Bool = false, + tags: [String] = [], + createdAt: Date = Date(timeIntervalSince1970: 0), + updatedAt: Date = Date(timeIntervalSince1970: 10) +) -> Todo { + Todo( + id: id, + isPinned: isPinned, + isCompleted: false, + isChecked: false, + number: number, + title: title, + content: "content", + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: tags, + category: category + ) +} + +func makeHomeWebPage( + title: String = "OpenAI", + urlString: String = "https://openai.com" +) -> WebPage { + let url = URL(string: urlString)! + return WebPage( + title: title, + url: url, + displayURL: url, + imageURL: nil + ) +} diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift new file mode 100644 index 00000000..383269f0 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift @@ -0,0 +1,145 @@ +// +// HomeFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Foundation +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct HomeFeatureTests { + @Test("HomeFeature fetchData는 홈 상태를 갱신한다") + func HomeFeature_fetchData는_홈_상태를_갱신한다() async throws { + let context = makeHomeFetchDataContext() + let adapter = HomeStoreTestAdapter( + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, + fetchTodosUseCase: context.fetchTodosUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy + ) + + try await verifyHomeFetchData( + adapter: adapter, + fetchTodosUseCaseSpy: context.fetchTodosUseCaseSpy, + fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy + ) + } + + @Test("HomeFeature webPageInput은 contentPicker 내부 내비게이션을 표시한다") + func HomeFeature_webPageInput은_contentPicker_내부_내비게이션을_표시한다() async throws { + let adapter = HomeStoreTestAdapter() + + try await verifyHomeWebPageInputAlert(adapter: adapter) + } + + @Test("HomeFeature tapTodoCategory는 editor를 지연 표시한다") + func HomeFeature_tapTodoCategory는_editor를_지연_표시한다() async throws { + let adapter = HomeStoreTestAdapter() + + try await verifyHomeTapTodoCategory(adapter: adapter) + } + + @Test("HomeFeature orderTodoCategory는 recentTodos category를 동기화하고 저장한다") + func HomeFeature_orderTodoCategory는_recentTodos_category를_동기화하고_저장한다() async throws { + let context = makeHomeOrderContext() + let adapter = HomeStoreTestAdapter( + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, + updatePreferencesUseCase: context.updatePreferencesUseCaseSpy, + fetchTodosUseCase: context.fetchTodosUseCaseSpy + ) + + try await verifyHomeOrderTodoCategory( + adapter: adapter, + updatePreferencesUseCaseSpy: context.updatePreferencesUseCaseSpy + ) + } + + @Test("HomeFeature addWebPage는 URL을 정규화하고 목록을 다시 불러온다") + func HomeFeature_addWebPage는_URL을_정규화하고_목록을_다시_불러온다() async throws { + let context = makeHomeAddWebPageContext() + let adapter = HomeStoreTestAdapter( + addWebPageUseCase: context.addWebPageUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy + ) + + try await verifyHomeAddWebPage( + adapter: adapter, + addWebPageUseCaseSpy: context.addWebPageUseCaseSpy, + fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCaseSpy: context.trackAnalyticsEventUseCaseSpy + ) + } + + @Test("웹페이지를 삭제하면 항목이 즉시 숨겨지고 삭제 유스케이스가 호출된다") + func 웹페이지를_삭제하면_항목이_즉시_숨겨지고_삭제_유스케이스가_호출된다() async throws { + let context = makeHomeDeleteContext() + let adapter = HomeStoreTestAdapter( + addWebPageUseCase: context.addWebPageUseCaseSpy, + deleteWebPageUseCase: context.deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCase: context.undoDeleteWebPageUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + ) + + await adapter.fetchData() + + let webPageItem = try #require(adapter.webPages.first) + + await adapter.deleteWebPage(webPageItem) + + #expect(adapter.webPages.filter { !$0.isHidden }.isEmpty) + + await waitUntil { + context.deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + } + + #expect(context.deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) + } + + @Test("웹페이지 삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다") + func 웹페이지_삭제를_되돌리면_되돌리기_유스케이스가_호출되고_숨김_상태가_해제된다() async throws { + let context = makeHomeDeleteContext() + let adapter = HomeStoreTestAdapter( + addWebPageUseCase: context.addWebPageUseCaseSpy, + deleteWebPageUseCase: context.deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCase: context.undoDeleteWebPageUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + ) + + await adapter.fetchData() + + let webPageItem = try #require(adapter.webPages.first) + + await adapter.deleteWebPage(webPageItem) + await adapter.undoDeleteWebPage() + + await waitUntil { + context.undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + } + + let restoredWebPageItem = try #require(adapter.webPages.first { + $0.url.absoluteString == "https://openai.com" + }) + + #expect(context.undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) + #expect(!restoredWebPageItem.isHidden) + } + + @Test("HomeFeature startObserving은 네트워크 연결 상태를 반영한다") + func HomeFeature_startObserving은_네트워크_연결_상태를_반영한다() async { + let networkUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() + let adapter = HomeStoreTestAdapter(networkConnectivityUseCase: networkUseCaseSpy) + + await adapter.startObserving() + + #expect(adapter.isNetworkConnected) + + networkUseCaseSpy.currentValueSubject.send(false) + await adapter.settle() + + #expect(!adapter.isNetworkConnected) + } +} diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index f6c9196f..8ccf2e91 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -54,12 +54,12 @@ final class TodoListStoreTestAdapter { } func onAppear() async { - await store.send(.onAppear) + await store.send(.view(.onAppear)) await drainReceivedActions() } func loadNextPage() async { - await store.send(.loadNextPage) + await store.send(.view(.loadNextPage)) await drainReceivedActions() } @@ -84,7 +84,7 @@ final class TodoListStoreTestAdapter { } func resetFilters() async { - await store.send(.resetFilters) + await store.send(.view(.resetFilters)) await drainReceivedActions() } @@ -117,7 +117,7 @@ final class TodoListStoreTestAdapter { } func setFullScreenCover(_ cover: TodoListFeature.FullScreenCoverState?) async { - await store.send(.setFullScreenCover(cover)) + await store.send(.store(.setFullScreenCover(cover))) } func dismissFullScreenCover() async { @@ -125,26 +125,26 @@ final class TodoListStoreTestAdapter { } func swipeTodo(_ todo: TodoListItem) async { - await store.send(.swipeTodo(todo)) + await store.send(.view(.swipeTodo(todo))) await drainReceivedActions() } func undoDelete() async { - await store.send(.undoDelete) + await store.send(.view(.undoDelete)) await drainReceivedActions() } func finishDeleteToast(_ todoId: String) async { - await store.send(.finishDeleteToast(todoId)) + await store.send(.view(.finishDeleteToast(todoId))) } func tapToggleCompleted(_ todo: TodoListItem) async { - await store.send(.tapToggleCompleted(todo)) + await store.send(.view(.tapToggleCompleted(todo))) await drainReceivedActions() } func tapTogglePinned(_ todo: TodoListItem) async { - await store.send(.tapTogglePinned(todo)) + await store.send(.view(.tapTogglePinned(todo))) await drainReceivedActions() } diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift index 338f28e2..52358f5d 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -149,17 +149,17 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { } func fetchNotifications() async { - await store.send(.fetchNotifications) + await store.send(.view(.fetchNotifications)) await drainReceivedActions() } func loadNextPage() async { - await store.send(.loadNextPage) + await store.send(.view(.loadNextPage)) await drainReceivedActions() } func toggleSortOption() async { - await store.send(.toggleSortOption) + await store.send(.view(.toggleSortOption)) await drainReceivedActions() } @@ -169,42 +169,42 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { } func toggleUnreadOnly() async { - await store.send(.toggleUnreadOnly) + await store.send(.view(.toggleUnreadOnly)) await drainReceivedActions() } func resetFilters() async { - await store.send(.resetFilters) + await store.send(.view(.resetFilters)) await drainReceivedActions() } func selectNotification(_ notificationId: String?) async { - await store.send(.selectNotification(notificationId)) + await store.send(.view(.selectNotification(notificationId))) await drainReceivedActions() } func toggleRead(_ item: PushNotificationItem) async { - await store.send(.toggleRead(item)) + await store.send(.view(.toggleRead(item))) await drainReceivedActions() } func deleteNotification(_ item: PushNotificationItem) async { - await store.send(.deleteNotification(item)) + await store.send(.view(.deleteNotification(item))) presentDeleteNotificationToast(item.id) await drainReceivedActions() } func undoDelete() async { - await store.send(.undoDelete) + await store.send(.view(.undoDelete)) await drainReceivedActions() } func finishDeleteToast(_ notificationId: String) async { - await store.send(.finishDeleteToast(notificationId)) + await store.send(.view(.finishDeleteToast(notificationId))) } func syncSheetPresentation(isCompactLayout: Bool) async { - await store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + await store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) } func dismissSheet() async { diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift index b84534a3..09290eb1 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestAssertions.swift @@ -35,13 +35,23 @@ func verifyTodayFetchData( adapter.todos.count == 5 } - #expect(fetchUseCaseSpy.queries.map(\.dueDateFilter) == [.withDueDate, .withoutDueDate]) - #expect(fetchUseCaseSpy.queries.map(\.completionFilter) == [.incomplete, .incomplete]) - #expect(fetchUseCaseSpy.queries.map(\.sortTarget) == [.dueDate, .updatedAt]) - #expect(fetchUseCaseSpy.queries.map(\.sortOrder) == [.oldest, .latest]) - #expect(fetchUseCaseSpy.queries.map(\.pageSize) == [20, 20]) - #expect(fetchUseCaseSpy.queries.map(\.fetchAllPages) == [true, true]) - #expect(fetchUseCaseSpy.cursors.allSatisfy { $0 == nil }) + let queries = await fetchUseCaseSpy.calledQueries() + let queriesByDueDateFilter = Dictionary( + uniqueKeysWithValues: queries.map { ($0.dueDateFilter, $0) } + ) + let cursors = await fetchUseCaseSpy.calledCursors() + + #expect(queries.count == 2) + #expect(Set(queries.map(\.dueDateFilter)) == Set([.withDueDate, .withoutDueDate])) + #expect(queries.allSatisfy { $0.completionFilter == .incomplete }) + #expect(queriesByDueDateFilter[.withDueDate]?.sortTarget == .dueDate) + #expect(queriesByDueDateFilter[.withDueDate]?.sortOrder == .oldest) + #expect(queriesByDueDateFilter[.withoutDueDate]?.sortTarget == .updatedAt) + #expect(queriesByDueDateFilter[.withoutDueDate]?.sortOrder == .latest) + #expect(queries.map(\.pageSize).allSatisfy { $0 == 20 }) + #expect(queries.map(\.fetchAllPages).allSatisfy { $0 }) + #expect(cursors.count == 2) + #expect(cursors.allSatisfy { $0 == nil }) #expect(adapter.todos.map(\.id) == ["focused", "overdue", "due-soon", "later", "unscheduled"]) #expect(adapter.summaryCounts == [ .all: 5, diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift index 5c2326cc..4279811c 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestSpies.swift @@ -13,8 +13,7 @@ import DevLogDomain final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { var pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] var error: Error? - private(set) var queries = [TodoQuery]() - private(set) var cursors = [TodoCursor?]() + private let recorder = TodayFetchTodosUseCaseCallRecorder() init( pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] = [ @@ -26,8 +25,7 @@ final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { } func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { - queries.append(query) - cursors.append(cursor) + await recorder.append(query: query, cursor: cursor) if let error { throw error @@ -35,6 +33,32 @@ final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { return pagesByFilter[query.dueDateFilter] ?? TodoPage(items: [], nextCursor: nil) } + + func calledQueries() async -> [TodoQuery] { + await recorder.queries() + } + + func calledCursors() async -> [TodoCursor?] { + await recorder.cursors() + } +} + +private actor TodayFetchTodosUseCaseCallRecorder { + var recordedQueries = [TodoQuery]() + var recordedCursors = [TodoCursor?]() + + func append(query: TodoQuery, cursor: TodoCursor?) { + recordedQueries.append(query) + recordedCursors.append(cursor) + } + + func queries() -> [TodoQuery] { + recordedQueries + } + + func cursors() -> [TodoCursor?] { + recordedCursors + } } final class TodayFetchTodoByIdUseCaseSpy: FetchTodoByIdUseCase { diff --git a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift b/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift deleted file mode 100644 index a04cee9f..00000000 --- a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// DeleteWebPageTests.swift -// DevLogPresentationTests -// -// Created by opfic on 4/6/26. -// - -import Testing -import Foundation -import DevLogDomain -@testable import DevLogPresentation - -@MainActor -struct DeleteWebPageTests { - @Test("웹페이지를 삭제하면 항목이 즉시 숨겨지고 되돌리기 토스트가 표시되며 삭제 유스케이스가 호출된다") - func 웹페이지를_삭제하면_항목이_즉시_숨겨지고_되돌리기_토스트가_표시되며_삭제_유스케이스가_호출된다() async throws { - ToastPresenter.reset() - - let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() - let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() - let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() - let deleteWebPageUseCaseSpy = DeleteWebPageUseCaseSpy() - let undoDeleteWebPageUseCaseSpy = UndoDeleteWebPageUseCaseSpy() - let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() - let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( - webPages: [ - WebPage( - title: "OpenAI", - url: URL(string: "https://openai.com")!, - displayURL: URL(string: "https://openai.com")!, - imageURL: nil - ) - ] - ) - let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() - let trackAnalyticsEventUseCaseSpy = TrackAnalyticsEventUseCaseSpy() - - let homeViewModel = HomeViewModel( - fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, - updatePreferencesUseCase: updateTodoCategoryPreferencesUseCaseSpy, - addWebPageUseCase: addWebPageUseCaseSpy, - deleteWebPageUseCase: deleteWebPageUseCaseSpy, - undoDeleteWebPageUseCase: undoDeleteWebPageUseCaseSpy, - fetchTodosUseCase: fetchTodosUseCaseSpy, - fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, - networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCaseSpy - ) - - homeViewModel.send(.fetchData) - await waitUntil { - !homeViewModel.state.webPages.isEmpty - } - - let webPageItem = try #require(homeViewModel.state.webPages.first) - - homeViewModel.send(.deleteWebPage(webPageItem)) - - #expect(homeViewModel.state.webPages.filter { !$0.isHidden }.isEmpty) - #expect(ToastPresenter.item?.message == String(localized: "common_undo")) - - await waitUntil { - deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] - } - - #expect(deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) - } - - @Test("웹페이지 삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다") - func 웹페이지_삭제를_되돌리면_되돌리기_유스케이스가_호출되고_숨김_상태가_해제된다() async throws { - ToastPresenter.reset() - - let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() - let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() - let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() - let deleteWebPageUseCaseSpy = DeleteWebPageUseCaseSpy() - let undoDeleteWebPageUseCaseSpy = UndoDeleteWebPageUseCaseSpy() - let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() - let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( - webPages: [ - WebPage( - title: "OpenAI", - url: URL(string: "https://openai.com")!, - displayURL: URL(string: "https://openai.com")!, - imageURL: nil - ) - ] - ) - let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() - let trackAnalyticsEventUseCaseSpy = TrackAnalyticsEventUseCaseSpy() - - let homeViewModel = HomeViewModel( - fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, - updatePreferencesUseCase: updateTodoCategoryPreferencesUseCaseSpy, - addWebPageUseCase: addWebPageUseCaseSpy, - deleteWebPageUseCase: deleteWebPageUseCaseSpy, - undoDeleteWebPageUseCase: undoDeleteWebPageUseCaseSpy, - fetchTodosUseCase: fetchTodosUseCaseSpy, - fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, - networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCaseSpy - ) - - homeViewModel.send(.fetchData) - await waitUntil { - !homeViewModel.state.webPages.isEmpty - } - - let webPageItem = try #require(homeViewModel.state.webPages.first) - - homeViewModel.send(.deleteWebPage(webPageItem)) - homeViewModel.send(.undoDeleteWebPage) - - await waitUntil { - undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] - } - - let restoredWebPageItem = try #require(homeViewModel.state.webPages.first { - $0.url.absoluteString == "https://openai.com" - }) - - #expect(undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) - #expect(!restoredWebPageItem.isHidden) - } -} - -private struct TrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { - func execute(_ event: AnalyticsEvent) { } -}