diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index c358cb2f..c967ec44 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -84,19 +84,10 @@ private extension AppGraph { &dependencies, updateTodoCategoryPreferencesUseCase: todoGraphSet .todoCategoryUseCaseGraph - .updateTodoCategoryPreferencesUseCase, - todoMutationEventBus: todoGraphSet.todoMutationEventBusGraph.todoMutationEventBus - ) - HomePresentationDependencyPreparation.prepareWebPage( - &dependencies, - addWebPageUseCase: webPageGraphSet.webPageUseCaseGraph.addWebPageUseCase, - deleteWebPageUseCase: webPageGraphSet.webPageUseCaseGraph.deleteWebPageUseCase, - undoDeleteWebPageUseCase: webPageGraphSet.webPageUseCaseGraph.undoDeleteWebPageUseCase, - fetchWebPagesUseCase: webPageGraphSet.webPageUseCaseGraph.fetchWebPagesUseCase + .updateTodoCategoryPreferencesUseCase ) HomePresentationDependencyPreparation.prepareTodo( &dependencies, - fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, networkConnectivityUseCase: networkConnectivityGraphSet .networkConnectivityUseCaseGraph .observeNetworkConnectivityUseCase @@ -107,7 +98,6 @@ private extension AppGraph { .userPreferencesUseCaseGraph .fetchRecentSearchQueriesUseCase, fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, - fetchWebPagesUseCase: webPageGraphSet.webPageUseCaseGraph.fetchWebPagesUseCase, updateRecentSearchQueriesUseCase: userPreferencesGraphSet .userPreferencesUseCaseGraph .updateRecentSearchQueriesUseCase @@ -181,6 +171,10 @@ private extension AppGraph { .userPreferencesUseCaseGraph .updateHeatmapActivityTypesUseCase ) + ProfilePresentationDependencyPreparation.prepareRecentTodos( + &dependencies, + todoMutationEventBus: todoGraphSet.todoMutationEventBusGraph.todoMutationEventBus + ) ProfilePresentationDependencyPreparation.prepareSettingsSession( &dependencies, deleteAuthUseCase: authenticationGraphSet.authenticationUseCaseGraph.deleteAuthUseCase, diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index 227ed3cc..8fda5635 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -13,12 +13,6 @@ import PresentationShared import TodayTab struct MainView: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var todoWindowCoordinator: TodoWindowCoordinator - @State private var homeViewCoordinator: HomeViewCoordinator - @State private var todayViewCoordinator: TodayViewCoordinator - @State private var pushNotificationListViewCoordinator: PushNotificationListViewCoordinator - @State private var profileViewCoordinator: ProfileViewCoordinator @Binding var selectedTab: MainTab @State private var store: StoreOf private let windowEvent: TodoEditorWindowEvent @@ -30,383 +24,74 @@ struct MainView: View { self._store = State(initialValue: Store(initialState: MainFeature.State()) { MainFeature() }) - self._todoWindowCoordinator = State(initialValue: TodoWindowCoordinator()) - self._homeViewCoordinator = State(initialValue: HomeViewCoordinator()) - self._todayViewCoordinator = State(initialValue: TodayViewCoordinator()) - self._pushNotificationListViewCoordinator = State( - initialValue: PushNotificationListViewCoordinator() - ) - self._profileViewCoordinator = State(initialValue: ProfileViewCoordinator()) - self._selectedTab = selectedTab self.windowEvent = windowEvent } var body: some View { - Group { - if isCompactLayout { - tabView - } else { - sidebarView(for: selectedTab) - } - } - .onAppear { - store.send(.view(.onAppear)) - homeViewCoordinator.bindTodoMutationEvent() - homeViewCoordinator.bindWindowEvent(windowEvent) - todoWindowCoordinator.bindWindowEvent(windowEvent) - } - .onChange(of: selectedTab, initial: true) { _, newValue in - store.send(.view(.selectedTabChanged(newValue))) - if newValue == .home { - homeViewCoordinator.fetchData() - } else if newValue == .today { - todayViewCoordinator.fetchData() - } else if newValue == .notification { - pushNotificationListViewCoordinator.fetchData() - } else if newValue == .profile { - profileViewCoordinator.fetchData() - } - } - .prominentAlert(store, state: \.alert, action: \.alert) - .toastHost() - } - - private var tabView: some View { TabView(selection: $selectedTab) { - homeView - .tabItem { - tabLabel(.home) - } - .tag(MainTab.home) - - todayView - .tabItem { - tabLabel(.today) - } - .tag(MainTab.today) - - notificationView - .tabItem { - tabLabel(.notification) - } - .badge(store.unreadPushCount) - .tag(MainTab.notification) - - profileView - .tabItem { - tabLabel(.profile) - } - .tag(MainTab.profile) - } - } - - @ViewBuilder - private func sidebarView(for selectedTab: MainTab) -> some View { - switch selectedTab { - case .home: - NavigationSplitView { - mainSidebar - } content: { - homeView - .navigationSplitViewColumnWidth(min: 350, ideal: 450, max: nil) - } detail: { - homeRegularDetailView + Tab(value: MainTab.home) { + tabContent(.home) + } label: { + tabLabel(.home) } - .environment(homeViewCoordinator.router) - case .today: - NavigationSplitView { - mainSidebar - } content: { - todayView - .navigationSplitViewColumnWidth(min: 350, ideal: 450, max: nil) - } detail: { - todayRegularDetailView + Tab(value: MainTab.today) { + tabContent(.today) + } label: { + tabLabel(.today) } - case .notification: - NavigationSplitView { - mainSidebar - } content: { - PushNotificationListView( - coordinator: pushNotificationListViewCoordinator, - isCompactLayout: isCompactLayout - ) - .navigationSplitViewColumnWidth(min: 350, ideal: 450, max: nil) - } detail: { - notificationRegularDetailView + Tab(value: MainTab.notification) { + tabContent(.notification) + } label: { + tabLabel(.notification) } - case .profile: - NavigationSplitView { - mainSidebar - } content: { - profileView - .navigationSplitViewColumnWidth(min: 350, ideal: 450, max: nil) - } detail: { - profileRegularDetailView + .badge(store.unreadPushCount) + Tab(value: MainTab.profile) { + tabContent(.profile) + } label: { + tabLabel(.profile) } } - } - - private var mainSidebar: some View { - List(selection: sidebarSelection) { - sidebarRow(.home) - sidebarRow(.today) - sidebarRow(.notification) - sidebarRow(.profile) + .tabViewStyle(.sidebarAdaptable) + .toastHost() + .onAppear { store.send(.view(.onAppear)) } + .onChange(of: selectedTab, initial: true) { _, tab in + store.send(.view(.selectedTabChanged(tab))) } - .listStyle(.sidebar) + .prominentAlert(store, state: \.alert, action: \.alert) } @ViewBuilder - private func sidebarRow(_ tab: MainTab) -> some View { - if tab == .notification { - tabLabel(tab) - .badge(store.unreadPushCount) - .tag(tab) - } else { - tabLabel(tab) - .tag(tab) - } + private func tabContent(_ tab: MainTab) -> some View { + let isSelected = selectedTab == tab + tabView(tab, isSelected: isSelected) + .environment(\.isTabContentActive, isSelected) } private func tabLabel(_ tab: MainTab) -> some View { - Label { - Text(tab.title) - } icon: { - Image(systemName: tab.symbolName) - } + Label(tab.title, systemImage: tab.symbolName) } @ViewBuilder - private var homeView: some View { - Group { - if isCompactLayout { - NavigationStack(path: homeNavigationPath) { - homeContentView - .navigationDestination(for: HomeRoute.self) { homeRoute in - homeDestinationView(homeRoute) - } - } - } else { - homeContentView - } - } - .environment(homeViewCoordinator.router) - } - - private var homeContentView: some View { - HomeView( - coordinator: homeViewCoordinator, - isCompactLayout: isCompactLayout - ) - } - - @ViewBuilder - private var homeRegularDetailView: some View { - NavigationStack(path: homeDetailPath) { - Group { - if let homeRoute = homeViewCoordinator.router.root { - homeDestinationView(homeRoute) - } else { - ContentUnavailableView( - String(localized: "home_select_detail", bundle: PresentationResources.bundle), - systemImage: "house" - ) - } - } - .navigationDestination(for: HomeRoute.self) { homeRoute in - homeDestinationView(homeRoute) - } - } - .background(Color(.systemGroupedBackground).ignoresSafeArea()) - } - - @ViewBuilder - private func homeDestinationView(_ homeRoute: HomeRoute) -> some View { - switch homeRoute { - case .category(let item): - TodoListView( - store: todoWindowCoordinator.makeListStore(category: item.todoCategory), - onSelectTodo: { todoId in - homeViewCoordinator.router.push(.todo(TodoIdItem(id: todoId))) - } - ) - .id(item.id) - case .todo(let item): - TodoDetailView(store: todoWindowCoordinator.makeDetailStore(todoId: item.id)) - .id(item.id) - case .webPage(let item): - WebView(url: item.url) - .navigationBarTitleDisplayMode(.inline) - .ignoresSafeArea() - .toolbar(.hidden, for: .tabBar) - .toolbar { - ToolbarItem(placement: .principal) { - Text(item.title) - .bold() - } - } - } - } - - @ViewBuilder - private var todayView: some View { - Group { - if isCompactLayout { - NavigationStack(path: todayNavigationPath) { - todayContentView - .navigationDestination(for: TodayRoute.self) { todayRoute in - todayDestinationView(todayRoute) - } - } - } else { - todayContentView - } - } - } - - private var todayContentView: some View { - TodayView( - coordinator: todayViewCoordinator, - isCompactLayout: isCompactLayout - ) - } - - @ViewBuilder - private var todayRegularDetailView: some View { - NavigationStack(path: todayDetailPath) { - Group { - if let todayRoute = todayViewCoordinator.router.root { - todayDestinationView(todayRoute) - } else { - ContentUnavailableView( - String(localized: "today_select_detail", bundle: PresentationResources.bundle), - systemImage: "sun.max" - ) - } - } - .navigationDestination(for: TodayRoute.self) { todayRoute in - todayDestinationView(todayRoute) - } - } - .background(Color(.systemGroupedBackground).ignoresSafeArea()) - } - - @ViewBuilder - private func todayDestinationView(_ todayRoute: TodayRoute) -> some View { - switch todayRoute { - case .todo(let item): - TodoDetailView(store: todoWindowCoordinator.makeDetailStore(todoId: item.id)) - .id(item.id) - } - } - - private var notificationView: some View { - PushNotificationListView( - coordinator: pushNotificationListViewCoordinator, - isCompactLayout: isCompactLayout - ) - } - - @ViewBuilder - private var notificationRegularDetailView: some View { - if let todoId = pushNotificationListViewCoordinator.selectedTodoId { - TodoDetailView( - store: pushNotificationListViewCoordinator.makeTodoDetailStore( - todoId: todoId - ) - ) - .id(todoId) - } else { - ContentUnavailableView( - String(localized: "push_notifications_select_detail", bundle: PresentationResources.bundle), - systemImage: "bell.badge" - ) - .background(Color(.systemGroupedBackground).ignoresSafeArea()) - } - } - - private var profileView: some View { - ProfileView( - coordinator: profileViewCoordinator, - isCompactLayout: isCompactLayout - ) - } - - private var profileRegularDetailView: some View { - ProfileRegularDetailView(coordinator: profileViewCoordinator) - } -} - -private extension MainView { - var isCompactLayout: Bool { - horizontalSizeClass == .compact - } - - var sidebarSelection: Binding { - Binding( - get: { selectedTab }, - set: { tab in - if let tab { - selectedTab = tab - } - } - ) - } - - var homeNavigationPath: Binding<[HomeRoute]> { - Binding( - get: { homeViewCoordinator.router.path }, - set: { homeViewCoordinator.router.path = $0 } - ) - } - - var homeDetailPath: Binding<[HomeRoute]> { - Binding( - get: { homeViewCoordinator.router.detailPath }, - set: { homeViewCoordinator.router.detailPath = $0 } - ) - } - - var todayNavigationPath: Binding<[TodayRoute]> { - Binding( - get: { todayViewCoordinator.router.path }, - set: { todayViewCoordinator.router.path = $0 } - ) - } - - var todayDetailPath: Binding<[TodayRoute]> { - Binding( - get: { todayViewCoordinator.router.detailPath }, - set: { todayViewCoordinator.router.detailPath = $0 } - ) - } - -} -private extension MainTab { - var title: String { - switch self { - case .home: - String(localized: "nav_home", bundle: PresentationResources.bundle) - case .today: - String(localized: "nav_today", bundle: PresentationResources.bundle) - case .notification: - String(localized: "nav_notifications", bundle: PresentationResources.bundle) - case .profile: - String(localized: "nav_profile", bundle: PresentationResources.bundle) - } - } - - var symbolName: String { - switch self { + private func tabView(_ tab: MainTab, isSelected: Bool) -> some View { + switch tab { case .home: - "house.fill" + HomeView( + isSelected: isSelected, + windowEvent: windowEvent + ) case .today: - "sun.max.fill" + TodayView( + isSelected: isSelected, + windowEvent: windowEvent + ) case .notification: - "bell.fill" + PushNotificationListView(isSelected: isSelected) case .profile: - "person.crop.circle.fill" + ProfileView( + isSelected: isSelected, + windowEvent: windowEvent + ) } } } diff --git a/Application/Presentation/Entry/Sources/Routing/MainTab.swift b/Application/Presentation/Entry/Sources/Routing/MainTab.swift index ed041251..0db4451f 100644 --- a/Application/Presentation/Entry/Sources/Routing/MainTab.swift +++ b/Application/Presentation/Entry/Sources/Routing/MainTab.swift @@ -5,9 +5,38 @@ // Created by opfic on 4/30/26. // -public enum MainTab: Hashable { +import Foundation +import PresentationShared + +public enum MainTab: Hashable, CaseIterable { case home case today case notification case profile + + var title: String { + switch self { + case .home: + String(localized: "nav_home", bundle: PresentationResources.bundle) + case .today: + String(localized: "nav_today", bundle: PresentationResources.bundle) + case .notification: + String(localized: "nav_notifications", bundle: PresentationResources.bundle) + case .profile: + String(localized: "nav_profile", bundle: PresentationResources.bundle) + } + } + + var symbolName: String { + switch self { + case .home: + "house.fill" + case .today: + "sun.max.fill" + case .notification: + "bell.fill" + case .profile: + "person.crop.circle.fill" + } + } } diff --git a/Application/Presentation/Entry/Sources/WindowGroup/TodoWindowCoordinator.swift b/Application/Presentation/Entry/Sources/WindowGroup/TodoWindowCoordinator.swift deleted file mode 100644 index 1f9c6fba..00000000 --- a/Application/Presentation/Entry/Sources/WindowGroup/TodoWindowCoordinator.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// TodoWindowCoordinator.swift -// Entry -// -// Created by opfic on 5/31/26. -// - -import Combine -import Foundation -import Domain -import PresentationShared - -@MainActor -@Observable -final class TodoWindowCoordinator { - @ObservationIgnored - @Dependency(\.trackAnalyticsEventUseCase) private var trackAnalyticsEventUseCase - @ObservationIgnored - private var listStore: StoreOf? - @ObservationIgnored - private var detailStore: StoreOf? - @ObservationIgnored - private var cancellable: AnyCancellable? - - func bindWindowEvent(_ windowEvent: TodoEditorWindowEvent) { - guard cancellable == nil else { return } - - cancellable = windowEvent.submits - .sink { [weak self] submit in - self?.handleTodoEditorSubmit(submit) - } - } - - func makeListStore(category: TodoCategory) -> StoreOf { - if let listStore, - listStore.category == category { - return listStore - } - - let listStore = Store(initialState: TodoListFeature.State(category: category)) { - TodoListFeature() - } - self.listStore = listStore - return listStore - } - - func makeDetailStore( - todoId: String, - showEditButton: Bool = true - ) -> StoreOf { - if let detailStore, - detailStore.todoId == todoId, - detailStore.showEditButton == showEditButton { - return detailStore - } - let detailStore = Store( - initialState: TodoDetailFeature.State( - todoId: todoId, - showEditButton: showEditButton - ) - ) { - TodoDetailFeature() - } - self.detailStore = detailStore - return detailStore - } - - private func handleTodoEditorSubmit(_ submit: TodoEditorWindowSubmit) { - switch submit { - case .create(let value): - trackAnalyticsEventUseCase.execute(.todoCreate) - if let listStore, - value.matchesCreate(category: listStore.category, source: .list) { - listStore.send(.view(.refresh)) - } - case .update(let value, let todo): - if let detailStore, - value.matchesEdit(todoId: detailStore.todoId) { - detailStore.send(.setTodo(todo)) - } - } - } -} diff --git a/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift b/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift index 16bb96c2..9ad1f99a 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift @@ -9,6 +9,7 @@ import SwiftUI import PresentationShared struct CategoryManageView: View { + @Environment(\.isTabContentActive) private var isTabContentActive @Bindable var store: StoreOf var body: some View { @@ -52,7 +53,10 @@ struct CategoryManageView: View { .navigationTitle(String(localized: "nav_todo_manage", bundle: PresentationResources.bundle)) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() - .sheet(item: $store.scope(state: \.categorySheet, action: \.categorySheet)) { sheetStore in + .sheet( + item: $store.scope(state: \.categorySheet, action: \.categorySheet) + .activePresentation(when: isTabContentActive) + ) { sheetStore in sheetContent(sheetStore) } .prominentAlert(store, state: \.alert, action: \.alert) diff --git a/Application/Presentation/HomeTab/Sources/Home/Common/Component/WebItemRow.swift b/Application/Presentation/HomeTab/Sources/Home/Common/Component/WebItemRow.swift deleted file mode 100644 index 5189127a..00000000 --- a/Application/Presentation/HomeTab/Sources/Home/Common/Component/WebItemRow.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// WebItemRow.swift -// HomeTab -// -// Created by 최윤진 on 2/24/26. -// - -import SwiftUI - -struct WebItemRow: View { - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) - let item: WebPageItem - let showsChevron: Bool - - init( - item: WebPageItem, - showsChevron: Bool - ) { - self.item = item - self.showsChevron = showsChevron - } - - var body: some View { - HStack { - thumbnail - .frame(width: labelWidth, height: labelWidth) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - VStack(alignment: .leading) { - Text(item.title) - .foregroundStyle(Color.primary) - .multilineTextAlignment(.leading) - .lineLimit(2) - Text(item.displayURL) - .foregroundStyle(Color.blue) - .underline() - } - Spacer() - if showsChevron { - Image(systemName: "chevron.right") - .font(.caption2.bold()) - .foregroundStyle(.gray) - } - } - .padding(.vertical, 4) - } - - @ViewBuilder - private var thumbnail: some View { - if let imageURL = item.imageURL { - AsyncImage(url: imageURL) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - case .empty: - ProgressView() - default: - placeholderImage - } - } - } else { - placeholderImage - } - } - - private var placeholderImage: some View { - Image(systemName: "globe") - .resizable() - .scaledToFit() - } -} diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift index 6ad0d986..f004334b 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift @@ -14,31 +14,6 @@ extension DependencyValues { 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 } - } - var homeNetworkConnectivityUseCase: ObserveNetworkConnectivityUseCase { get { self[HomeNetworkConnectivityUseCaseKey.self] } set { self[HomeNetworkConnectivityUseCaseKey.self] = newValue } @@ -55,56 +30,6 @@ private enum HomeUpdatePreferencesUseCaseKey: DependencyKey { } } -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 - } -} - private enum HomeNetworkConnectivityUseCaseKey: DependencyKey { static var liveValue: ObserveNetworkConnectivityUseCase { preconditionFailure("ObserveNetworkConnectivityUseCase must be provided.") diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift index 4d26fac5..a7f4a15d 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift @@ -32,96 +32,24 @@ extension HomeFeature { 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(.store(.setAlert(isPresented: true))) } 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:))))) - await send(.store(.setSheet(nil))) - } catch { - await send(.store(.setAlert(isPresented: true, type: .error))) - } - await send(.loading(.end(target: LoadingTarget.overlay.target, mode: .delayed))) - } - } - func trackTodoCreateEffect() -> Effect { .run { [trackAnalyticsEventUseCase] _ in trackAnalyticsEventUseCase.execute(.todoCreate) } } - func deleteWebPageEffect(_ page: WebPageItem) -> Effect { - .run { [deleteWebPageUseCase] send in - do { - try await deleteWebPageUseCase.execute( - id: page.id, - urlString: page.url.absoluteString - ) - } catch { - await send(.store(.handleWebPageDeleteFailure(page.id))) - await send(.store(.setAlert(isPresented: true, type: .error))) - } - } - } - - func undoDeleteWebPageEffect(_ webPage: DeletedWebPage) -> Effect { - .run { [undoDeleteWebPageUseCase, addWebPageUseCase] send in - do { - try await undoDeleteWebPageUseCase.execute(webPage.id) - try await addWebPageUseCase.execute(webPage.urlString) - } catch { - await send(.store(.setWebPageHidden(webPage.id, 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))) + await send(.store(.setAlert(isPresented: true))) } } } @@ -135,17 +63,6 @@ extension HomeFeature { .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, @@ -158,7 +75,7 @@ extension HomeFeature { state.selectedTodoCategory = nil } case .contentPicker: - state.sheet = isPresented ? .contentPicker(.init()) : state.showContentPicker ? nil : state.sheet + state.sheet = isPresented ? .contentPicker : state.showContentPicker ? nil : state.sheet case .searchView: state.fullScreenCover = isPresented ? .search : nil } @@ -166,67 +83,26 @@ extension HomeFeature { static func setAlert( _ state: inout State, - isPresented: Bool, - type: AlertType? + isPresented: Bool ) { - guard isPresented, let type else { + guard isPresented else { state.alert = nil return } - state.alert = alertState(for: type) + state.alert = alertState() } - static func alertState(for type: AlertType) -> AlertState { - let title: String - let message: String - - switch type { - case .invalidURL: - title = String(localized: "home_invalid_url_title", bundle: PresentationResources.bundle) - message = String(localized: "home_invalid_url_message", bundle: PresentationResources.bundle) - case .error: - title = String(localized: "common_error_title", bundle: PresentationResources.bundle) - message = String(localized: "common_error_message", bundle: PresentationResources.bundle) - } - + static func alertState() -> AlertState { return AlertState { - TextState(title) + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) } actions: { ButtonState(role: .cancel) { TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) } } message: { - TextState(message) + TextState(String(localized: "common_error_message", bundle: PresentationResources.bundle)) } } - 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/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift index 9028d31f..5837d661 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift @@ -17,16 +17,14 @@ struct HomeFeature { @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 deletedWebPage: DeletedWebPage? var loading = LoadingFeature.State() - var showContentPicker: Bool { sheet?.contentPickerState != nil } + var showContentPicker: Bool { + if case .contentPicker? = sheet { return true } + return false + } var showTodoEditor: Bool { fullScreenCover?.todoEditor != nil } @@ -38,22 +36,6 @@ struct HomeFeature { 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) - } - } - - struct DeletedWebPage: Equatable { - let id: String - let urlString: String } enum Action: BindableAction, Equatable { @@ -68,50 +50,25 @@ struct HomeFeature { enum ViewAction: Equatable { case startObserving case fetchData - case refreshRecentTodos - case refreshWebPages - case finishDeleteWebPageToast(String) case todoEditorCreated case tapManageTodoCategory case tapTodoCategory(TodoCategory) - 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(String, Bool) - case handleWebPageDeleteFailure(String) + case setAlert(isPresented: Bool) 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(CategoryManageFeature.State) - case contentPicker(ContentPickerState) + case contentPicker var categoryManageState: CategoryManageFeature.State? { get { @@ -123,30 +80,12 @@ struct HomeFeature { self = .reorderTodo(newValue) } } - - 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 categoryManage(CategoryManageFeature.Action) - case contentPicker(ContentPicker) - - @CasePathable - enum ContentPicker: Equatable { - case tapWebPageInput - case webPageInput(PresentationAction) - } } @ObservableState @@ -182,31 +121,17 @@ struct HomeFeature { 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(\.homeNetworkConnectivityUseCase) var networkConnectivityUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase @Dependency(\.continuousClock) var clock @@ -276,20 +201,7 @@ private extension HomeFeature { 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.deletedWebPage?.urlString == urlString { - state.deletedWebPage = nil - } + return fetchTodoCategoryPreferencesEffect() case .todoEditorCreated: state.fullScreenCover = nil state.selectedTodoCategory = nil @@ -303,30 +215,6 @@ private extension HomeFeature { state.selectedTodoCategory = category state.sheet = nil return delayedTodoEditorEffect() - case .addWebPage: - guard let normalizedURL = Self.normalizedWebPageURL(state.webPageURLInput) else { - Self.setAlert(&state, isPresented: true, type: .invalidURL) - return .none - } - 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.deletedWebPage = DeletedWebPage( - id: page.id, - urlString: page.url.absoluteString - ) - state.webPages[index].isHidden = true - return deleteWebPageEffect(page) - case .undoDeleteWebPage: - guard let webPage = state.deletedWebPage else { return .none } - if let index = state.webPages.firstIndex(where: { $0.id == webPage.id }) { - state.webPages[index].isHidden = false - } - state.deletedWebPage = nil - return undoDeleteWebPageEffect(webPage) } return .none @@ -337,7 +225,6 @@ private extension HomeFeature { state: inout State ) -> Effect { state.preferences = preferences - state.recentTodos = Self.syncRecentTodos(state.recentTodos, preferences: preferences) state.sheet = nil return updateTodoCategoryPreferencesEffect(preferences) } @@ -353,26 +240,10 @@ private extension HomeFeature { 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 id, let isHidden): - if let index = state.webPages.firstIndex(where: { $0.id == id }) { - state.webPages[index].isHidden = isHidden - } - case .handleWebPageDeleteFailure(let id): - if let index = state.webPages.firstIndex(where: { $0.id == id }) { - state.webPages[index].isHidden = false - } else { - state.needsWebPageRefresh = true - } + case .setAlert(let isPresented): + Self.setAlert(&state, isPresented: isPresented) 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 @@ -388,40 +259,5 @@ private struct HomeSheetFeature: Reducer { .ifCaseLet(\.reorderTodo, action: \.categoryManage) { CategoryManageFeature() } - .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/Presentation/HomeTab/Sources/Home/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift index cbdbcb39..2f8469b1 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine import Domain import PresentationShared @@ -13,31 +14,63 @@ public 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 + @State private var path = [HomeRoute]() + @State private var searchStore: StoreOf + @State private var store: StoreOf + private let isSelected: Bool + private let windowEvent: TodoEditorWindowEvent public init( - coordinator: HomeViewCoordinator, - isCompactLayout: Bool + isSelected: Bool, + windowEvent: TodoEditorWindowEvent ) { - self.coordinator = coordinator - self.isCompactLayout = isCompactLayout - self.store = coordinator.store + @Dependency(\.homeFetchRecentSearchQueriesUseCase) var fetchRecentSearchQueriesUseCase + self._store = State(initialValue: Store(initialState: HomeFeature.State()) { + HomeFeature() + }) + self._searchStore = State(initialValue: Store( + initialState: SearchFeature.State( + recentQueries: fetchRecentSearchQueriesUseCase.execute() + ) + ) { + SearchFeature() + }) + self.isSelected = isSelected + self.windowEvent = windowEvent } public var body: some View { - List { - todoSection - recentTodoSection - webPageSection + NavigationStack(path: $path) { + List { + todoSection + } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nav_home", bundle: PresentationResources.bundle)) + .navigationDestination(for: HomeRoute.self, destination: destinationView) + .toolbar { toolbar } + } + .onAppear { store.send(.view(.startObserving)) } + .onChange(of: isSelected, initial: true) { _, isSelected in + if isSelected { + store.send(.view(.fetchData)) + } + } + .onReceive(windowEvent.submits) { submit in + guard case .create(let value) = submit, + value.matchesCreate(source: .home) else { return } + store.send(.view(.todoEditorCreated)) } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_home", bundle: PresentationResources.bundle)) - .toolbar { toolbar } .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(item: $store.scope(state: \.sheet, action: \.sheet), content: sheetContent) - .fullScreenCover(item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover), content: coverContent) + .sheet( + item: $store.scope(state: \.sheet, action: \.sheet) + .activePresentation(when: isSelected), + content: sheetContent + ) + .fullScreenCover( + item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + .activePresentation(when: isSelected), + content: coverContent + ) } private var todoSection: some View { @@ -70,76 +103,6 @@ public struct HomeView: View { }) } - private var recentTodoSection: some View { - Section { - if store.isRecentTodosLoading && store.recentTodos.isEmpty { - LoadingView() - } else if store.recentTodos.isEmpty { - HStack { - Spacer() - Text(String(localized: "home_recent_empty", bundle: PresentationResources.bundle)) - .font(.callout) - Spacer() - } - } else { - ForEach(store.recentTodos, id: \.id) { todo in - recentTodoRow(todo) - } - } - } header: { - HStack { - Text(String(localized: "home_recent_title", bundle: PresentationResources.bundle)) - .foregroundStyle(Color.primary) - .font(.title2.bold()) - Spacer() - } - .listRowInsets(EdgeInsets()) - } - } - - private var webPageSection: some View { - Section { - let webPages = store.webPages.filter { !$0.isHidden } - if store.isWebPageLoading { - LoadingView() - .id(UUID()) // id 부여를 통해 렌더링 강제 - } else if store.needsWebPageRefresh { - Button { - store.send(.view(.refreshWebPages)) - } label: { - HStack { - Spacer() - Text(String(localized: "home_web_refresh_required", bundle: PresentationResources.bundle)) - .font(.callout) - .multilineTextAlignment(.center) - Spacer() - } - } - .buttonStyle(.plain) - } else if webPages.isEmpty { - HStack { - Spacer() - Text(String(localized: "home_web_empty", bundle: PresentationResources.bundle)) - .font(.callout) - Spacer() - } - } else { - ForEach(webPages, id: \.id) { page in - webResultRow(page) - } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - } - } header: { - HStack { - Text("Web Page", bundle: PresentationResources.bundle) - .foregroundStyle(Color.primary) - .font(.title2.bold()) - Spacer() - } - .listRowInsets(EdgeInsets()) - } - } - @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { @@ -164,8 +127,7 @@ public struct HomeView: View { @ViewBuilder private func sheetContent(_ sheetStore: Store) -> some View { - if let pickerStore = sheetStore.scope(state: \.contentPickerState, action: \.contentPicker) { - @Bindable var pickerStore = pickerStore + if case .contentPicker = sheetStore.state { NavigationStack { List { Section { @@ -191,66 +153,8 @@ public struct HomeView: View { .foregroundStyle(Color(.label)) } - Section { - Button { - pickerStore.send(.tapWebPageInput) - } label: { - labelImage( - text: "URL", - systemName: "globe", - imageColor: .blue - ) - } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - } header: { - Text("Web Page", bundle: PresentationResources.bundle) - .foregroundStyle(Color(.label)) - } - } - .navigationDestination( - item: $pickerStore.scope(state: \.webPageInput, action: \.webPageInput) - ) { _ in - Form { - Section { - TextField( - "", - text: $store.webPageURLInput, - prompt: Text("https://", bundle: PresentationResources.bundle) - ) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - } footer: { - Text(String(localized: "home_webpage_input_message", bundle: PresentationResources.bundle)) - } - } - .scrollDisabled(true) - .navigationTitle( - Text( - String( - localized: "home_webpage_input_title", - bundle: PresentationResources.bundle - ) - ) - ) - .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 - .toolbar { - if store.isAppending { - if #available(iOS 26.0, *) { - ToolbarSpacer(.fixed, placement: .topBarTrailing) - } - ToolbarItem(placement: .topBarTrailing) { - ProgressView() - } - } else { - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "home_add", bundle: PresentationResources.bundle)) { - store.send(.view(.addWebPage)) - } - } - } - } } - .navigationTitle(Text(String(localized: "nav_home_content", bundle: PresentationResources.bundle))) + .navigationTitle(Text("TODO")) .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -278,80 +182,43 @@ public struct HomeView: View { TodoEditorView(store: todoEditorStore) } case .search: - SearchView(store: coordinator.makeSearchStore()) + SearchView(store: searchStore) } } @ViewBuilder - private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { - if isCompactLayout { - NavigationLink(value: HomeRoute.category(item)) { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) - } - } else { - Button { - coordinator.router.replace(with: .category(item)) - } label: { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) - } - .buttonStyle(.plain) - } - } - - @ViewBuilder - private func recentTodoRow(_ item: RecentTodoItem) -> some View { - Group { - if isCompactLayout { - NavigationLink(value: HomeRoute.todo(TodoIdItem(id: item.id))) { - RecentTodoRow(todo: item) - } - } else { - Button { - coordinator.router.replace(with: .todo(TodoIdItem(id: item.id))) - } label: { - RecentTodoRow(todo: item) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - } + private func destinationView(_ route: HomeRoute) -> some View { + switch route { + case .category(let item): + TodoListView( + store: Store(initialState: TodoListFeature.State(category: item.todoCategory)) { + TodoListFeature() + }, + windowEvent: windowEvent, + onSelectTodo: { path.append(.todo(TodoIdItem(id: $0))) } + ) + .id(item.id) + case .todo(let item): + TodoDetailView( + store: Store( + initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: true) + ) { + TodoDetailFeature() + }, + windowEvent: windowEvent + ) + .id(item.id) } - .todoDetailPreview(todoId: item.id) } @ViewBuilder - private func webResultRow(_ item: WebPageItem) -> some View { - Group { - if isCompactLayout { - NavigationLink(value: HomeRoute.webPage(item)) { - WebItemRow(item: item, showsChevron: false) - } - } else { - Button { - coordinator.router.replace(with: .webPage(item)) - } label: { - WebItemRow(item: item, showsChevron: false) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - store.send(.view(.deleteWebPage(item))) - presentDeleteWebPageToast(item.url.absoluteString) - } label: { - Label(String(localized: "common_delete", bundle: PresentationResources.bundle), systemImage: "trash") - } + private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { + NavigationLink(value: HomeRoute.category(item)) { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) } } @@ -388,75 +255,9 @@ public struct HomeView: View { } } - private func presentDeleteWebPageToast(_ urlString: String) { - ToastPresenter.present( - message: String(localized: "common_undo", bundle: PresentationResources.bundle), - systemImage: "arrow.uturn.left", - duration: 5, - font: .caption, - multilineTextAlignment: .center, - action: { - store.send(.view(.undoDeleteWebPage)) - }, - onDismiss: { - store.send(.view(.finishDeleteWebPageToast(urlString))) - } - ) - } - } public enum HomeRoute: Hashable { case category(TodoCategoryItem) case todo(TodoIdItem) - case webPage(WebPageItem) -} - -private struct RecentTodoRow: View { - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) - let todo: RecentTodoItem - - var body: some View { - let category = TodoCategoryItem(from: todo.category) - HStack(alignment: .top, spacing: 12) { - RoundedRectangle(cornerRadius: 8) - .fill(category.color) - .frame(width: labelWidth, height: labelWidth) - .overlay { - Image(systemName: category.symbolName) - .foregroundStyle(Color.white) - .font(.title3) - } - - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - if todo.isPinned { - Image(systemName: "star.fill") - .font(.caption.weight(.semibold)) - .foregroundStyle(.orange) - } - Text(todo.title) - .foregroundStyle(Color.primary) - .font(.headline) - .lineLimit(1) - Text("#\(todo.number)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.gray) - .fixedSize(horizontal: true, vertical: false) - } - - HStack(spacing: 6) { - Text(category.localizedName) - .font(.caption.weight(.semibold)) - .foregroundStyle(category.color) - - RelativeTimeText(date: todo.updatedAt) - } - - if !todo.tags.isEmpty { - TagList(todo.tags, lineLimit: 1) - } - } - } - } } diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeViewCoordinator.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeViewCoordinator.swift deleted file mode 100644 index e2b585b2..00000000 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeViewCoordinator.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// HomeViewCoordinator.swift -// HomeTab -// -// Created by opfic on 5/10/26. -// - -import Combine -import Foundation -import Domain -import PresentationShared - -@MainActor -@Observable -public final class HomeViewCoordinator { - let store: StoreOf - public let router = NavigationRouter() - @ObservationIgnored - @Dependency(\.homeTodoMutationEventBus) private var todoMutationEventBus - @ObservationIgnored - @Dependency(\.homeFetchRecentSearchQueriesUseCase) private var fetchRecentSearchQueriesUseCase - @ObservationIgnored - private var cancellables = Set() - @ObservationIgnored - private var isTodoMutationEventBound = false - @ObservationIgnored - private var isWindowEventBound = false - - public init() { - self.store = Store(initialState: HomeFeature.State()) { - HomeFeature() - } - self.store.send(.view(.startObserving)) - } - - public func fetchData() { - store.send(.view(.fetchData)) - } - - public func refreshRecentTodos() { - store.send(.view(.refreshRecentTodos)) - } - - public func bindTodoMutationEvent() { - guard isTodoMutationEventBound == false else { return } - isTodoMutationEventBound = true - - todoMutationEventBus.observe() - .receive(on: DispatchQueue.main) - .sink { [weak self] event in - guard let self else { return } - switch event { - case .updated, .deleted, .restored: - self.refreshRecentTodos() - } - } - .store(in: &cancellables) - } - - public func bindWindowEvent(_ windowEvent: TodoEditorWindowEvent) { - guard isWindowEventBound == false else { return } - isWindowEventBound = true - - windowEvent.submits - .receive(on: DispatchQueue.main) - .sink { [weak self] submit in - guard case .create(let value) = submit, - value.matchesCreate(source: .home) else { return } - self?.store.send(.view(.todoEditorCreated)) - } - .store(in: &cancellables) - } - - func makeSearchStore() -> StoreOf { - Store( - initialState: SearchFeature.State( - recentQueries: fetchRecentSearchQueriesUseCase.execute() - ) - ) { - SearchFeature() - } - } -} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift index 15cf5718..b2f571e5 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift @@ -11,32 +11,15 @@ import PresentationShared public enum HomeDependencyPreparation { public static func prepareTodoCategory( _ dependencies: inout DependencyValues, - updateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase, - todoMutationEventBus: TodoMutationEventBus + updateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase ) { dependencies.homeUpdateTodoCategoryPreferencesUseCase = updateTodoCategoryPreferencesUseCase - dependencies.homeTodoMutationEventBus = todoMutationEventBus - } - - public static func prepareWebPage( - _ dependencies: inout DependencyValues, - addWebPageUseCase: AddWebPageUseCase, - deleteWebPageUseCase: DeleteWebPageUseCase, - undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase, - fetchWebPagesUseCase: FetchWebPagesUseCase - ) { - dependencies.homeAddWebPageUseCase = addWebPageUseCase - dependencies.homeDeleteWebPageUseCase = deleteWebPageUseCase - dependencies.homeUndoDeleteWebPageUseCase = undoDeleteWebPageUseCase - dependencies.homeFetchWebPagesUseCase = fetchWebPagesUseCase } public static func prepareTodo( _ dependencies: inout DependencyValues, - fetchTodosUseCase: FetchTodosUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCase ) { - dependencies.homeFetchTodosUseCase = fetchTodosUseCase dependencies.homeNetworkConnectivityUseCase = networkConnectivityUseCase } @@ -44,34 +27,21 @@ public enum HomeDependencyPreparation { _ dependencies: inout DependencyValues, fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase, fetchTodosUseCase: FetchTodosUseCase, - fetchWebPagesUseCase: FetchWebPagesUseCase, updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase ) { dependencies.homeFetchRecentSearchQueriesUseCase = fetchRecentSearchQueriesUseCase dependencies.searchFetchTodosUseCase = fetchTodosUseCase - dependencies.searchFetchWebPagesUseCase = fetchWebPagesUseCase dependencies.searchUpdateRecentQueriesUseCase = updateRecentSearchQueriesUseCase } } extension DependencyValues { - var homeTodoMutationEventBus: TodoMutationEventBus { - get { self[HomeTodoMutationEventBusKey.self] } - set { self[HomeTodoMutationEventBusKey.self] = newValue } - } - var homeFetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase { get { self[HomeFetchRecentSearchQueriesUseCaseKey.self] } set { self[HomeFetchRecentSearchQueriesUseCaseKey.self] = newValue } } } -private enum HomeTodoMutationEventBusKey: DependencyKey { - static var liveValue: TodoMutationEventBus { - preconditionFailure("TodoMutationEventBus must be provided.") - } -} - private enum HomeFetchRecentSearchQueriesUseCaseKey: DependencyKey { static var liveValue: FetchRecentSearchQueriesUseCase { preconditionFailure("FetchRecentSearchQueriesUseCase must be provided.") diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift b/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift index b2f2401c..7181a773 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift @@ -18,11 +18,9 @@ struct SearchFeature { var loading = LoadingFeature.State() var isSearching = false var searchQuery = "" - var webPages: [WebPageItem] = [] var todos: [TodoListItem] = [] var recentQueries = OrderedSet() var showAllTodos = false - var showAllWebPages = false let contentsLimit = 5 init(recentQueries: [String] = []) { @@ -41,22 +39,10 @@ struct SearchFeature { return Array(todos.prefix(contentsLimit)) } - var visibleWebPages: [WebPageItem] { - if showAllWebPages { - return webPages - } - - return Array(webPages.prefix(contentsLimit)) - } - var shouldShowMoreTodos: Bool { !showAllTodos && contentsLimit < todos.count } - var shouldShowMoreWebPages: Bool { - !showAllWebPages && contentsLimit < webPages.count - } - var isHashOnlyQuery: Bool { searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == "#" } @@ -70,12 +56,10 @@ struct SearchFeature { case removeRecentQuery(String) case clearRecentQueries case setShowAllTodos(Bool) - case setShowAllWebPages(Bool) case store(StoreAction) case loading(LoadingFeature.Action) enum StoreAction: Equatable { - case fetchWebPage([WebPageItem]) case fetchTodos([TodoListItem]) case applySearchQuery(String) case setAlert(Bool) @@ -89,7 +73,6 @@ struct SearchFeature { @Dependency(\.continuousClock) var clock @Dependency(\.searchFetchTodosUseCase) var fetchTodosUseCase - @Dependency(\.searchFetchWebPagesUseCase) var fetchWebPagesUseCase @Dependency(\.searchUpdateRecentQueriesUseCase) var updateRecentSearchQueriesUseCase private let maxRecentQueries = 20 @@ -118,10 +101,8 @@ struct SearchFeature { } case .binding(\.searchQuery): state.showAllTodos = false - state.showAllWebPages = false let trimmed = state.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty || trimmed == "#" { - state.webPages = [] state.todos = [] return Self.cancelSearchEffect(isLoading: state.isLoading) } else { @@ -132,8 +113,6 @@ struct SearchFeature { } case .binding: break - case .store(.fetchWebPage(let items)): - state.webPages = items case .store(.fetchTodos(let items)): state.todos = items case .addRecentQuery(let query): @@ -154,7 +133,6 @@ struct SearchFeature { case .store(.applySearchQuery(let query)): let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty || trimmed == "#" { - state.webPages = [] state.todos = [] return Self.cancelSearchEffect(isLoading: state.isLoading) } else { @@ -164,8 +142,6 @@ struct SearchFeature { state.alert = isPresented ? Self.alertState() : nil case .setShowAllTodos(let shouldShowAll): state.showAllTodos = shouldShowAll - case .setShowAllWebPages(let shouldShowAll): - state.showAllWebPages = shouldShowAll case .loading: break } @@ -182,11 +158,6 @@ extension DependencyValues { set { self[SearchFetchTodosUseCaseKey.self] = newValue } } - var searchFetchWebPagesUseCase: FetchWebPagesUseCase { - get { self[SearchFetchWebPagesUseCaseKey.self] } - set { self[SearchFetchWebPagesUseCaseKey.self] = newValue } - } - var searchUpdateRecentQueriesUseCase: UpdateRecentSearchQueriesUseCase { get { self[SearchUpdateRecentQueriesUseCaseKey.self] } set { self[SearchUpdateRecentQueriesUseCaseKey.self] = newValue } @@ -203,16 +174,6 @@ private enum SearchFetchTodosUseCaseKey: DependencyKey { } } -private enum SearchFetchWebPagesUseCaseKey: DependencyKey { - static var liveValue: FetchWebPagesUseCase { - preconditionFailure("FetchWebPagesUseCase must be provided.") - } - - static var testValue: FetchWebPagesUseCase { - liveValue - } -} - private enum SearchUpdateRecentQueriesUseCaseKey: DependencyKey { static var liveValue: UpdateRecentSearchQueriesUseCase { preconditionFailure("UpdateRecentSearchQueriesUseCase must be provided.") @@ -244,16 +205,11 @@ private extension SearchFeature { } func fetchEffect(_ query: String, isLoading: Bool) -> Effect { - let skipsWebPages = query.hasPrefix("#") - - return .run { [fetchTodosUseCase, fetchWebPagesUseCase] send in + .run { [fetchTodosUseCase] send in do { - async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) - let webPages = skipsWebPages ? [] : try await fetchWebPagesUseCase.execute(query) - let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } - let webPageItems = webPages.map { WebPageItem(from: $0) } + let todos = try await fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) + let todoItems = todos.items.compactMap { TodoListItem(from: $0) } await send(.store(.fetchTodos(todoItems))) - await send(.store(.fetchWebPage(webPageItems))) if isLoading { await send(.loading(.end(target: .default, mode: .immediate))) } diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift index d65e6044..0e324006 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift @@ -29,15 +29,6 @@ struct SearchView: View { ) { TodoDetailFeature() }) - case .web(let page): - WebView(url: page.url) - .ignoresSafeArea() - .toolbar { - ToolbarItem(placement: .principal) { - Text(page.title) - .bold() - } - } } } .onAppear { store.send(.onAppear) } @@ -65,7 +56,7 @@ struct SearchView: View { hashGuide } else if store.isLoading { LoadingView() - } else if store.webPages.isEmpty && store.todos.isEmpty { + } else if store.todos.isEmpty { emptySearchResult } else { ScrollView { @@ -126,9 +117,6 @@ struct SearchView: View { if !store.todos.isEmpty { todoResults } - if !store.webPages.isEmpty { - webPages - } } .padding(.vertical, 8) } @@ -160,33 +148,6 @@ struct SearchView: View { .padding(.horizontal, 16) } - private var webPages: some View { - let pages = store.visibleWebPages - - return VStack(alignment: .leading, spacing: 12) { - Text("Web Pages", bundle: PresentationResources.bundle) - .font(.headline) - .foregroundStyle(Color(.label)) - Divider() - LazyVStack(spacing: 0) { - ForEach(pages, id: \.id) { page in - webResultRow(page) - } - } - .padding(.top, -12) - if store.shouldShowMoreWebPages { - Button(String(localized: "search_show_more", bundle: PresentationResources.bundle)) { - store.send(.setShowAllWebPages(true)) - } - .font(.subheadline) - .foregroundStyle(Color.gray) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 4) - } - } - .padding(.horizontal, 16) - } - private func todoResultRow(_ item: TodoListItem) -> some View { Button { router.push(Path.todo(item.id)) @@ -199,15 +160,6 @@ struct SearchView: View { .todoDetailPreview(todoId: item.id) } - private func webResultRow(_ item: WebPageItem) -> some View { - NavigationLink(value: Path.web(item)) { - VStack(spacing: 0) { - WebItemRow(item: item, showsChevron: true) - Divider() - } - } - } - private var recentQueries: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -251,6 +203,5 @@ struct SearchView: View { private enum Path: Hashable { case todo(String) - case web(WebPageItem) } } diff --git a/Application/Presentation/HomeTab/Sources/Home/Structure/WebPageItem.swift b/Application/Presentation/HomeTab/Sources/Home/Structure/WebPageItem.swift deleted file mode 100644 index fdc4184e..00000000 --- a/Application/Presentation/HomeTab/Sources/Home/Structure/WebPageItem.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// WebPageItem.swift -// HomeTab -// -// Created by 최윤진 on 2/9/26. -// - -import SwiftUI -import Domain -import PresentationShared - -public struct WebPageItem: Identifiable, Hashable { - private let metadata: WebPage - public var isHidden = false - - public init(from metadata: WebPage) { - self.metadata = metadata - } - - public var id: String { metadata.id } - public var title: String { - metadata.title - ?? String(localized: "web_page_missing_title", bundle: PresentationResources.bundle) - } - public var url: URL { metadata.url } - public var displayURL: String { metadata.displayURL.absoluteString } - public var imageURL: URL? { metadata.imageURL } -} diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift index 71284ef1..460511cd 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift @@ -13,47 +13,15 @@ import PresentationShared @MainActor func verifyHomeFetchData( - adapter: HomeStoreTestAdapter, - fetchTodosUseCaseSpy: FetchTodosUseCaseSpy, - fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy + adapter: HomeStoreTestAdapter ) 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 @@ -95,60 +63,12 @@ func verifyHomeOrderTodoCategory( await adapter.orderTodoCategory(items) #expect(adapter.preferences == items) - #expect(adapter.recentTodos.last?.category == updatedCategory.category) #expect(updatePreferencesUseCaseSpy.updates == [items.map(\.preference)]) #expect(!adapter.showCategoryManage) } -@MainActor -func verifyHomeAddWebPage( - adapter: HomeStoreTestAdapter, - addWebPageUseCaseSpy: AddWebPageUseCaseSpy, - fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy, - trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy -) async throws { - await adapter.setPresentation(.contentPicker, true) - 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.showContentPicker) - #expect(!adapter.showAlert) -} - -@MainActor -func verifyHomeAddWebPageFailureKeepsSheet( - adapter: HomeStoreTestAdapter, - addWebPageUseCaseSpy: AddWebPageUseCaseSpy -) async throws { - await adapter.setPresentation(.contentPicker, true) - await adapter.updateWebPageURLInput("openai.com") - await adapter.addWebPage() - - await waitUntil { - addWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] - && adapter.showAlert - } - - #expect(adapter.showContentPicker) - #expect(adapter.alertType == .error) -} - struct HomeFetchDataContext { let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy - let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy - let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy } func makeHomeFetchDataContext() -> HomeFetchDataContext { @@ -167,92 +87,20 @@ func makeHomeFetchDataContext() -> HomeFetchDataContext { ) ] - 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 + fetchPreferencesUseCaseSpy: fetchPreferencesUseCaseSpy ) } 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() + updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy() ) } diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift index 308201ec..6304acc2 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift @@ -24,10 +24,12 @@ func waitUntil( } final class FetchTodoCategoryPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { + private(set) var executeCount = 0 var todoCategoryPreferences: [TodoCategoryPreference] = [] func execute() async throws -> [TodoCategoryPreference] { - todoCategoryPreferences + executeCount += 1 + return todoCategoryPreferences } } @@ -39,58 +41,6 @@ final class UpdateTodoCategoryPreferencesUseCaseSpy: UpdateTodoCategoryPreferenc } } -final class AddWebPageUseCaseSpy: AddWebPageUseCase { - var error: Error? - private(set) var calledUrlStrings: [String] = [] - - func execute(_ urlString: String) async throws { - calledUrlStrings.append(urlString) - if let error { - throw error - } - } -} - -final class DeleteWebPageUseCaseSpy: DeleteWebPageUseCase { - private(set) var calls: [(id: String, urlString: String)] = [] - - func execute(id: String, urlString: String) async throws { - calls.append((id, urlString)) - } -} - -final class UndoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCase { - private(set) var calledIDs: [String] = [] - - func execute(_ id: String) async throws { - calledIDs.append(id) - } -} - -final class FetchTodosUseCaseSpy: FetchTodosUseCase { - var todoPage = TodoPage(items: [], nextCursor: nil) - private(set) var queries: [TodoQuery] = [] - - func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { - queries.append(query) - return todoPage - } -} - -final class FetchWebPagesUseCaseSpy: FetchWebPagesUseCase { - var webPages: [WebPage] - private(set) var calledQueries: [String] = [] - - init(webPages: [WebPage]) { - self.webPages = webPages - } - - func execute(_ query: String) async throws -> [WebPage] { - calledQueries.append(query) - return webPages - } -} - final class ObserveNetworkConnectivityUseCaseSpy: ObserveNetworkConnectivityUseCase { let currentValueSubject = CurrentValueSubject(true) diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift index 32b7c333..5686cdd1 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift @@ -19,44 +19,16 @@ struct HomeStoreTestAdapter { 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 showCategoryManage: Bool { store.state.sheet?.categoryManageState != nil } - 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", bundle: PresentationResources.bundle)) { - return .invalidURL - } - if title == TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) { - 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 @@ -68,11 +40,6 @@ struct HomeStoreTestAdapter { } withDependencies: { $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase $0.homeUpdateTodoCategoryPreferencesUseCase = updatePreferencesUseCase - $0.homeAddWebPageUseCase = addWebPageUseCase - $0.homeDeleteWebPageUseCase = deleteWebPageUseCase - $0.homeUndoDeleteWebPageUseCase = undoDeleteWebPageUseCase - $0.homeFetchTodosUseCase = fetchTodosUseCase - $0.homeFetchWebPagesUseCase = fetchWebPagesUseCase $0.homeNetworkConnectivityUseCase = networkConnectivityUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.continuousClock = clock @@ -91,20 +58,10 @@ struct HomeStoreTestAdapter { 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)) @@ -126,29 +83,6 @@ struct HomeStoreTestAdapter { await drainReceivedActions() } - func updateWebPageURLInput(_ input: String) async { - await store.send(.binding(.set(\.webPageURLInput, 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) @@ -174,46 +108,3 @@ final class HomeTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { 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( - id: String = "web-page-id", - title: String = "OpenAI", - urlString: String = "https://openai.com" -) -> WebPage { - let url = URL(string: urlString)! - return WebPage( - id: id, - title: title, - url: url, - displayURL: url, - imageURL: nil - ) -} diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index 97d30c13..163185ee 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -6,7 +6,6 @@ // import Testing -import Foundation import Domain import PresentationShared @testable import HomeTab @@ -17,23 +16,10 @@ struct HomeFeatureTests { 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 + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy ) - } - - @Test("HomeFeature webPageInput은 contentPicker 내부 내비게이션을 표시한다") - func HomeFeature_webPageInput은_contentPicker_내부_내비게이션을_표시한다() async throws { - let adapter = HomeStoreTestAdapter() - try await verifyHomeWebPageInputAlert(adapter: adapter) + try await verifyHomeFetchData(adapter: adapter) } @Test("HomeFeature tapTodoCategory는 editor를 지연 표시한다") @@ -49,8 +35,6 @@ struct HomeFeatureTests { let trackSpy = HomeTrackAnalyticsEventUseCaseSpy() let adapter = HomeStoreTestAdapter( fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, - fetchTodosUseCase: context.fetchTodosUseCaseSpy, - fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, trackAnalyticsEventUseCase: trackSpy ) @@ -58,23 +42,19 @@ struct HomeFeatureTests { await adapter.todoEditorCreated() await waitUntil { - context.fetchTodosUseCaseSpy.queries.count == 1 - && context.fetchWebPagesUseCaseSpy.calledQueries == [""] + context.fetchPreferencesUseCaseSpy.executeCount == 1 && trackSpy.hasTrackedTodoCreate } #expect(!adapter.showTodoEditor) - #expect(adapter.recentTodos.map(\.id) == ["todo-1", "todo-2"]) - #expect(adapter.webPages.map(\.url.absoluteString) == ["https://openai.com"]) } - @Test("HomeFeature orderTodoCategory는 recentTodos category를 동기화하고 저장한다") - func HomeFeature_orderTodoCategory는_recentTodos_category를_동기화하고_저장한다() async throws { + @Test("HomeFeature orderTodoCategory는 카테고리 설정을 저장한다") + func HomeFeature_orderTodoCategory는_카테고리_설정을_저장한다() async throws { let context = makeHomeOrderContext() let adapter = HomeStoreTestAdapter( fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, - updatePreferencesUseCase: context.updatePreferencesUseCaseSpy, - fetchTodosUseCase: context.fetchTodosUseCaseSpy + updatePreferencesUseCase: context.updatePreferencesUseCaseSpy ) try await verifyHomeOrderTodoCategory( @@ -83,94 +63,6 @@ struct HomeFeatureTests { ) } - @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("HomeFeature addWebPage 실패는 입력 시트를 유지한다") - func HomeFeature_addWebPage_실패는_입력_시트를_유지한다() async throws { - let context = makeHomeAddWebPageContext() - context.addWebPageUseCaseSpy.error = HomeTestError.failure - let adapter = HomeStoreTestAdapter( - addWebPageUseCase: context.addWebPageUseCaseSpy, - fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, - trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy - ) - - try await verifyHomeAddWebPageFailureKeepsSheet( - adapter: adapter, - addWebPageUseCaseSpy: context.addWebPageUseCaseSpy - ) - } - - @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.calls.count == 1 - } - - #expect(context.deleteWebPageUseCaseSpy.calls.first?.id == "web-page-id") - #expect(context.deleteWebPageUseCaseSpy.calls.first?.urlString == "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.calledIDs == ["web-page-id"] - } - - let restoredWebPageItem = try #require(adapter.webPages.first { - $0.url.absoluteString == "https://openai.com" - }) - - #expect(context.undoDeleteWebPageUseCaseSpy.calledIDs == ["web-page-id"]) - #expect(!restoredWebPageItem.isHidden) - } - @Test("HomeFeature startObserving은 네트워크 연결 상태를 반영한다") func HomeFeature_startObserving은_네트워크_연결_상태를_반영한다() async { let networkUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() @@ -186,7 +78,3 @@ struct HomeFeatureTests { #expect(!adapter.isNetworkConnected) } } - -private enum HomeTestError: Error { - case failure -} diff --git a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift index 051c69c9..46a4bfc0 100644 --- a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift +++ b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTestDoubles.swift @@ -19,27 +19,22 @@ struct SearchStoreTestAdapter { var isSearching: Bool { store.state.isSearching } var isLoading: Bool { store.state.isLoading } var todos: [TodoListItem] { store.state.todos } - var webPages: [WebPageItem] { store.state.webPages } var recentQueries: [String] { Array(store.state.recentQueries) } var showAllTodos: Bool { store.state.showAllTodos } - var showAllWebPages: Bool { store.state.showAllWebPages } var isHashOnlyQuery: Bool { store.state.isHashOnlyQuery } var alert: AlertState? { store.state.alert } init( recentQueries: [String] = [], initialTodos: [TodoListItem] = [], - initialWebPages: [WebPageItem] = [], isSearching: Bool = false, isLoading: Bool = false, - fetchWebPagesUseCase: FetchWebPagesUseCase = SearchFetchWebPagesUseCaseSpy(), fetchTodosUseCase: FetchTodosUseCase = SearchFetchTodosUseCaseSpy(), updateRecentQueriesUseCase: UpdateRecentSearchQueriesUseCase = SearchUpdateRecentQueriesUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { var state = SearchFeature.State(recentQueries: recentQueries) state.todos = initialTodos - state.webPages = initialWebPages state.isSearching = isSearching if isLoading { state.loading.setImmediateLoading() @@ -47,7 +42,6 @@ struct SearchStoreTestAdapter { store = TestStore(initialState: state) { SearchFeature() } withDependencies: { - $0.searchFetchWebPagesUseCase = fetchWebPagesUseCase $0.searchFetchTodosUseCase = fetchTodosUseCase $0.searchUpdateRecentQueriesUseCase = updateRecentQueriesUseCase $0.continuousClock = ContinuousClock() @@ -94,22 +88,14 @@ struct SearchStoreTestAdapter { } } - func setShowAllWebPages(_ value: Bool) async { - await store.send(.setShowAllWebPages(value)) { - $0.showAllWebPages = value - } - } - func setSearchQuery(_ query: String) async { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let wasLoading = store.state.isLoading await store.send(.binding(.set(\.searchQuery, query))) { $0.searchQuery = query $0.showAllTodos = false - $0.showAllWebPages = false if trimmed.isEmpty || $0.isHashOnlyQuery { $0.todos = [] - $0.webPages = [] } } if wasLoading { @@ -138,17 +124,11 @@ struct SearchStoreTestAdapter { await store.receive(.store(.applySearchQuery(query))) } - func receiveSearchResults( - todos: [TodoListItem], - webPages: [WebPageItem] - ) async { + func receiveSearchResults(todos: [TodoListItem]) async { let wasLoading = store.state.isLoading await store.receive(.store(.fetchTodos(todos))) { $0.todos = todos } - await store.receive(.store(.fetchWebPage(webPages))) { - $0.webPages = webPages - } if wasLoading { await receiveEndLoading() } @@ -213,26 +193,6 @@ final class SearchFetchTodosUseCaseSpy: FetchTodosUseCase { } } -final class SearchFetchWebPagesUseCaseSpy: FetchWebPagesUseCase { - var webPages: [WebPage] - var error: Error? - private(set) var queries = [String]() - - init(webPages: [WebPage] = []) { - self.webPages = webPages - } - - func execute(_ query: String) async throws -> [WebPage] { - queries.append(query) - - if let error { - throw error - } - - return webPages - } -} - final class SearchUpdateRecentQueriesUseCaseSpy: UpdateRecentSearchQueriesUseCase { private(set) var queries = [[String]]() @@ -267,21 +227,6 @@ func makeSearchTodo( ) } -func makeSearchWebPage( - id: String = "web-page-id", - title: String? = "Web", - urlString: String = "https://example.com" -) -> WebPage { - let url = URL(string: urlString)! - return WebPage( - id: id, - title: title, - url: url, - displayURL: url, - imageURL: nil - ) -} - func expectedSearchErrorAlert() -> AlertState { AlertState { TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) diff --git a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift index 71224b11..9a930e73 100644 --- a/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Search/SearchFeatureTests.swift @@ -82,12 +82,9 @@ struct SearchFeatureTests { @Test("setSearchQuery는 표시 범위를 초기화하고 디바운스 후 검색 결과를 반영한다") func setSearchQuery는_표시_범위를_초기화하고_디바운스_후_검색_결과를_반영한다() async { let todo = makeSearchTodo(id: "todo-1", title: "Swift") - let webPage = makeSearchWebPage(title: "Swift", urlString: "https://swift.org") let todoSpy = SearchFetchTodosUseCaseSpy(page: TodoPage(items: [todo], nextCursor: nil)) - let webSpy = SearchFetchWebPagesUseCaseSpy(webPages: [webPage]) let clock = TestClock() let adapter = SearchStoreTestAdapter( - fetchWebPagesUseCase: webSpy, fetchTodosUseCase: todoSpy, configureDependencies: { $0.continuousClock = clock @@ -95,53 +92,39 @@ struct SearchFeatureTests { ) await adapter.setShowAllTodos(true) - await adapter.setShowAllWebPages(true) await adapter.setSearchQuery(" swift ") await clock.advance(by: .milliseconds(400)) await adapter.receiveAppliedSearchQuery("swift") - await adapter.receiveSearchResults( - todos: [TodoListItem(from: todo)!], - webPages: [WebPageItem(from: webPage)] - ) + await adapter.receiveSearchResults(todos: [TodoListItem(from: todo)!]) #expect(adapter.searchQuery == " swift ") #expect(!adapter.showAllTodos) - #expect(!adapter.showAllWebPages) #expect(todoSpy.queries.map(\.keyword) == ["swift"]) - #expect(webSpy.queries == ["swift"]) #expect(adapter.todos == [TodoListItem(from: todo)]) - #expect(adapter.webPages == [WebPageItem(from: webPage)]) #expect(!adapter.isLoading) } @Test("빈 검색어는 검색 결과를 비우고 로딩을 종료한다") func 빈_검색어는_검색_결과를_비우고_로딩을_종료한다() async { let todo = TodoListItem(from: makeSearchTodo(id: "todo-1"))! - let webPage = WebPageItem(from: makeSearchWebPage(urlString: "https://swift.org")) let adapter = SearchStoreTestAdapter( initialTodos: [todo], - initialWebPages: [webPage], isLoading: true ) await adapter.setSearchQuery(" ") #expect(adapter.todos.isEmpty) - #expect(adapter.webPages.isEmpty) #expect(!adapter.isLoading) } @Test("# 단독 검색어는 안내 상태로 전환하고 조회를 시작하지 않는다") func 해시_단독_검색어는_안내_상태로_전환하고_조회를_시작하지_않는다() async { let todo = TodoListItem(from: makeSearchTodo(id: "todo-1"))! - let webPage = WebPageItem(from: makeSearchWebPage(urlString: "https://swift.org")) let todoSpy = SearchFetchTodosUseCaseSpy() - let webSpy = SearchFetchWebPagesUseCaseSpy() let adapter = SearchStoreTestAdapter( initialTodos: [todo], - initialWebPages: [webPage], isLoading: true, - fetchWebPagesUseCase: webSpy, fetchTodosUseCase: todoSpy ) @@ -149,28 +132,20 @@ struct SearchFeatureTests { #expect(adapter.isHashOnlyQuery) #expect(adapter.todos.isEmpty) - #expect(adapter.webPages.isEmpty) #expect(!adapter.isLoading) #expect(todoSpy.queries.isEmpty) - #expect(webSpy.queries.isEmpty) } - @Test("# 검색어는 WebPage 조회를 생략하고 Todo만 반영한다") - func 해시태그_검색어는_WebPage_조회를_생략하고_Todo만_반영한다() async { + @Test("# 검색어는 Todo 검색 결과를 반영한다") + func 해시태그_검색어는_Todo_검색_결과를_반영한다() async { let todo = makeSearchTodo(id: "todo-1", title: "Issue") let todoSpy = SearchFetchTodosUseCaseSpy(page: TodoPage(items: [todo], nextCursor: nil)) - let webSpy = SearchFetchWebPagesUseCaseSpy(webPages: [makeSearchWebPage()]) - let adapter = SearchStoreTestAdapter(fetchWebPagesUseCase: webSpy, fetchTodosUseCase: todoSpy) + let adapter = SearchStoreTestAdapter(fetchTodosUseCase: todoSpy) await adapter.applySearchQuery(" #123 ") - await adapter.receiveSearchResults( - todos: [TodoListItem(from: todo)!], - webPages: [] - ) + await adapter.receiveSearchResults(todos: [TodoListItem(from: todo)!]) #expect(todoSpy.queries.map(\.keyword) == ["#123"]) - #expect(webSpy.queries.isEmpty) - #expect(adapter.webPages.isEmpty) #expect(adapter.todos == [TodoListItem(from: todo)]) } diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListFeature.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListFeature.swift index a8ce5808..fecfb297 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListFeature.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListFeature.swift @@ -70,7 +70,7 @@ struct PushNotificationListFeature { case toggleUnreadOnly case resetFilters case selectNotification(String?) - case syncSheetPresentation(isCompactLayout: Bool) + case syncSheetPresentation } enum Sheet: Equatable { @@ -88,6 +88,7 @@ struct PushNotificationListFeature { enum CancelID: Hashable { case fetchNotifications + case fetchNotificationsAndObserve case observeNotifications case toggleRead } @@ -172,7 +173,12 @@ private extension PushNotificationListFeature { ) case .fetchNotifications: state.nextCursor = nil - return fetchNotificationsPageEffect(query: state.query, cursor: nil) + return .concatenate( + .cancel(id: CancelID.observeNotifications), + fetchNotificationsPageEffect(query: state.query, cursor: nil), + .send(.view(.startObserving)) + ) + .cancellable(id: CancelID.fetchNotificationsAndObserve, cancelInFlight: true) case .loadNextPage: guard state.nextCursor != nil, !state.isLoading else { return .none } return fetchNotificationsPageEffect( @@ -231,8 +237,8 @@ private extension PushNotificationListFeature { guard !item.isRead else { return .none } state.notifications[index].isRead = true return toggleReadEffect(notificationId: item.id, todoId: item.todoId, rollbackRead: false) - case .syncSheetPresentation(let isCompactLayout): - if let todoId = state.selectedTodoId?.id, isCompactLayout { + case .syncSheetPresentation: + if let todoId = state.selectedTodoId?.id { state.sheet = .init(todoId: todoId) } else { state.sheet = nil @@ -280,6 +286,7 @@ private extension PushNotificationListFeature { fetchNotificationsPageEffect(query: query, cursor: nil), .send(.view(.startObserving)) ) + .cancellable(id: CancelID.fetchNotificationsAndObserve, cancelInFlight: true) ) } diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift index e126286d..c1ac687d 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift @@ -15,17 +15,19 @@ public struct PushNotificationListView: View { @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 @State private var headerOffset: CGFloat = 0 @State private var isScrollTrackingEnabled = false - @Bindable var store: StoreOf - let coordinator: PushNotificationListViewCoordinator - let isCompactLayout: Bool + @State private var store: StoreOf + private let isSelected: Bool - public init( - coordinator: PushNotificationListViewCoordinator, - isCompactLayout: Bool - ) { - self.coordinator = coordinator - self.isCompactLayout = isCompactLayout - self.store = coordinator.store + public init(isSelected: Bool) { + @Dependency(\.fetchPushNotificationQueryUseCase) var fetchQueryUseCase + self._store = State(initialValue: Store( + initialState: PushNotificationListFeature.State( + query: fetchQueryUseCase.execute() + ) + ) { + PushNotificationListFeature() + }) + self.isSelected = isSelected } public var body: some View { @@ -47,14 +49,16 @@ public struct PushNotificationListView: View { .navigationTitle(String(localized: "nav_push_notifications", bundle: PresentationResources.bundle)) } .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(item: sheetStore) { store in + .sheet(item: sheetStore.activePresentation(when: isSelected)) { store in sheetContent(store) } - .task(id: isCompactLayout) { - store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) + .onChange(of: isSelected, initial: true) { _, isSelected in + if isSelected { + store.send(.view(.fetchNotifications)) + } } .onChange(of: store.selectedTodoId?.id, initial: true) { - store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) + store.send(.view(.syncSheetPresentation)) } .overlay { if store.isLoading { @@ -98,23 +102,12 @@ public struct PushNotificationListView: View { index: Int, notifications: [PushNotificationItem] ) -> some View { - if isCompactLayout { - Button { - store.send(.view(.selectNotification(notification.id))) - } label: { - notificationRowContent(notification, index: index, notifications: notifications) - } - .buttonStyle(.plain) - } else { + Button { + store.send(.view(.selectNotification(notification.id))) + } label: { notificationRowContent(notification, index: index, notifications: notifications) - .onTapGesture { - store.send(.view(.selectNotification(notification.id))) - } - .accessibilityAddTraits(.isButton) - .accessibilityAction { - store.send(.view(.selectNotification(notification.id))) - } } + .buttonStyle(.plain) } private func notificationRowContent( @@ -124,7 +117,7 @@ public struct PushNotificationListView: View { ) -> some View { notificationRow( notification, - isSelected: !isCompactLayout && store.selectedNotificationId == notification.id + isSelected: false ) .onAppear { let lastId = notifications.last?.id @@ -364,7 +357,14 @@ public struct PushNotificationListView: View { _ sheetStore: Store ) -> some View { NavigationStack { - TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: sheetStore.todoId)) + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State( + todoId: sheetStore.todoId, + showEditButton: false + ) + ) { + TodoDetailFeature() + }) .id(sheetStore.todoId) .toolbar { ToolbarLeadingButton { @@ -379,11 +379,7 @@ public struct PushNotificationListView: View { private var sheetStore: Binding< Store?> { - if isCompactLayout { - $store.scope(state: \.sheet, action: \.sheet) - } else { - .constant(nil) - } + $store.scope(state: \.sheet, action: \.sheet) } private func presentDeleteNotificationToast(_ notificationId: String) { diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListViewCoordinator.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListViewCoordinator.swift deleted file mode 100644 index 88d3ca0d..00000000 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListViewCoordinator.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// PushNotificationListViewCoordinator.swift -// NotificationTab -// -// Created by opfic on 5/29/26. -// - -import Foundation -import Domain -import PresentationShared - -@MainActor -@Observable -public final class PushNotificationListViewCoordinator { - let store: StoreOf - @ObservationIgnored - private var todoDetailStore: StoreOf? - @ObservationIgnored - private var fetchNotificationsTask: Task? - - public init() { - @Dependency(\.fetchPushNotificationQueryUseCase) var fetchQueryUseCase - - self.store = Store( - initialState: PushNotificationListFeature.State( - query: fetchQueryUseCase.execute() - ) - ) { - PushNotificationListFeature() - } - } - - public var selectedTodoId: String? { - store.selectedTodoId?.id - } - - public func fetchData() { - fetchNotificationsTask?.cancel() - store.send(.view(.stopObserving)) - let query = store.query - let task = store.send(.view(.fetchNotifications)) - fetchNotificationsTask = Task { [store] in - await task.finish() - guard !Task.isCancelled, store.query == query else { return } - store.send(.view(.startObserving)) - } - } - - public func makeTodoDetailStore(todoId: String) -> StoreOf { - if let todoDetailStore, - todoDetailStore.todoId == todoId, - !todoDetailStore.showEditButton { - return todoDetailStore - } - - let todoDetailStore = Store( - initialState: TodoDetailFeature.State( - todoId: todoId, - showEditButton: false - ) - ) { - TodoDetailFeature() - } - self.todoDetailStore = todoDetailStore - return todoDetailStore - } -} diff --git a/Application/Presentation/NotificationTab/Tests/PushNotificationListFeatureTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotificationListFeatureTests.swift index 5bf83d2b..6777bfb0 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotificationListFeatureTests.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotificationListFeatureTests.swift @@ -307,8 +307,8 @@ struct PushNotificationListFeatureTests { #expect(adapter.notifications.first?.isRead == true) } - @Test("syncSheetPresentation은 layout에 따라 시트 상태를 동기화한다") - func syncSheetPresentation은_layout에_따라_시트_상태를_동기화한다() async throws { + @Test("syncSheetPresentation은 선택한 Todo를 시트로 표시한다") + func syncSheetPresentation은_선택한_Todo를_시트로_표시한다() async throws { let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ PushNotificationPage( items: [ @@ -322,17 +322,7 @@ struct PushNotificationListFeatureTests { await adapter.fetchNotifications() await adapter.selectNotification("notification-1") - await adapter.syncSheetPresentation(isCompactLayout: true) - - #expect(adapter.sheetTodoId == "todo-1") - - await adapter.syncSheetPresentation(isCompactLayout: false) - - #expect(adapter.sheetTodoId == nil) - #expect(adapter.selectedNotificationId == "notification-1") - #expect(adapter.selectedTodoId?.id == "todo-1") - - await adapter.syncSheetPresentation(isCompactLayout: true) + await adapter.syncSheetPresentation() #expect(adapter.sheetTodoId == "todo-1") diff --git a/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift b/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift index ca6d789c..25f2263a 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift @@ -146,8 +146,8 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { await store.send(.view(.finishDeleteToast(notificationId))) } - func syncSheetPresentation(isCompactLayout: Bool) async { - await store.send(.view(.syncSheetPresentation(isCompactLayout: isCompactLayout))) + func syncSheetPresentation() async { + await store.send(.view(.syncSheetPresentation)) } func dismissSheet() async { diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index d3ec50e5..8e4dd417 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -383,58 +383,7 @@ } } }, - "home_add" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "추가" - } - } - } - }, - "home_invalid_url_message" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Please enter a valid URL." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "올바른 URL을 입력해주세요." - } - } - } - }, - "home_invalid_url_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Check URL" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL 확인" - } - } - } - }, - "home_recent_empty" : { + "profile_recent_empty" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -451,19 +400,19 @@ } } }, - "home_recent_title" : { + "profile_recent_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Recently Updated" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "최근 수정" + "value" : "최근 활동" } } } @@ -485,85 +434,6 @@ } } }, - "home_web_empty" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Saved web pages appear here." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "저장한 웹 페이지가 표시돼요." - } - } - } - }, - "home_web_refresh_required" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Couldn't update web pages. Tap to refresh." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "웹페이지를 불러오지 못했어요. 탭해서 새로고침해주세요." - } - } - } - }, - "home_webpage_input_message" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter a web page URL." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "웹페이지 URL을 입력해주세요." - } - } - } - }, - "home_webpage_input_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add URL" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "URL 추가" - } - } - } - }, - "https://" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "https://" - } - } - } - }, "login_alert_email_unavailable_message" : { "extractionState" : "manual", "localizations" : { @@ -751,23 +621,6 @@ } } }, - "nav_home_content" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Content" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "컨텐츠" - } - } - } - }, "nav_notifications" : { "extractionState" : "manual", "localizations" : { @@ -3619,46 +3472,7 @@ } } } - }, - "Web Page" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Web Page" - } - } - } - }, - "Web Pages" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Web Pages" - } - } - } - }, - "web_page_missing_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unable to find this web page" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "웹페이지를 찾을 수 없어요" - } - } - } } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift new file mode 100644 index 00000000..17ce6e5a --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift @@ -0,0 +1,51 @@ +// +// ActivePresentation.swift +// PresentationShared +// +// Created by opfic on 9/9/26. +// + +import SwiftUI + +public extension EnvironmentValues { + var isTabContentActive: Bool { + get { self[TabContentActiveKey.self] } + set { self[TabContentActiveKey.self] = newValue } + } + + private struct TabContentActiveKey: EnvironmentKey { + static let defaultValue = true + } +} + +public extension Binding where Value == Bool { + func activePresentation(when isActive: Bool) -> Binding { + Binding( + get: { isActive && wrappedValue }, + set: { isPresented in + if isActive || !isPresented { + wrappedValue = isPresented + } + } + ) + } +} + +public extension Binding { + func activePresentation(when isActive: Bool) -> Binding + where Value == Wrapped? { + Binding( + get: { isActive ? wrappedValue : nil }, + set: { value in + switch value { + case .some: + if isActive { + wrappedValue = value + } + case .none: + wrappedValue = nil + } + } + ) + } +} diff --git a/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift b/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift index 56d57cea..a14b6822 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift @@ -10,8 +10,6 @@ import Domain import MarkdownRenderer struct TodoMarkdownContentView: View { - @State private var tabBarHeight = CGFloat.zero - let content: String let referenceItems: [Int: TodoReferenceItem] var onOpenTodoID: ((String) -> Void)? @@ -20,18 +18,9 @@ struct TodoMarkdownContentView: View { MarkdownRendererView( markdown: content, references: rendererReferences, - obscuredBottomInset: tabBarHeight, onOpenReferenceID: onOpenTodoID ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(.container, edges: ignoredSafeAreaEdges) - .onAppear { updateTabBarHeight() } - } - - private var ignoredSafeAreaEdges: Edge.Set { - if #available(iOS 26.0, *) { return .bottom } - - return [] } private var rendererReferences: [Int: MarkdownRendererReference] { @@ -63,16 +52,4 @@ struct TodoMarkdownContentView: View { return "data:image/png;base64,\(data.base64EncodedString())" } - - @MainActor - private func updateTabBarHeight() { - guard #available(iOS 26.0, *) else { return } - - let window = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first { $0.isKeyWindow } - - tabBarHeight = window?.rootViewController?.visibleTabBarHeight ?? .zero - } } diff --git a/Application/Presentation/PresentationShared/Sources/Common/WebView.swift b/Application/Presentation/PresentationShared/Sources/Common/WebView.swift deleted file mode 100644 index 0654a6ae..00000000 --- a/Application/Presentation/PresentationShared/Sources/Common/WebView.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// WebView.swift -// PresentationShared -// -// Created by opfic on 5/23/25. -// - -import SwiftUI -import WebKit - -public struct WebView: UIViewRepresentable { - let url: URL - - public init(url: URL) { - self.url = url - } - - public func makeUIView(context: Context) -> WKWebView { - let webView = WKWebView() - return webView - } - - public func updateUIView(_ uiView: WKWebView, context: Context) { - let request = URLRequest(url: url) - uiView.load(request) - } -} diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift index f169c85e..8b8978f0 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift @@ -16,21 +16,41 @@ public extension View { state: KeyPath?>, action: CaseKeyPath> ) -> some View where State: ObservableState { + modifier( + ProminentAlertModifier( + store: store, + alertState: state, + alertAction: action + ) + ) + } +} + +private struct ProminentAlertModifier: ViewModifier +where State: ObservableState { + @Environment(\.isTabContentActive) private var isTabContentActive + + let store: Store + let alertState: KeyPath?> + let alertAction: CaseKeyPath> + + @preconcurrency @MainActor + func body(content: Content) -> some View { @Bindable var store = store - let item = $store.scope(state: state, action: action) + let item = $store.scope(state: alertState, action: alertAction) let alertStore = item.wrappedValue - let alertState = store.state[keyPath: state] + let state = store.state[keyPath: alertState] - alert( - alertState.map(\.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: Binding(item), - presenting: alertState, - actions: { alertState in - ForEach(alertState.buttons) { button in - let usesDefaultAction = alertState.usesDefaultAction(for: button) + content.alert( + state.map(\.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: Binding(item).activePresentation(when: isTabContentActive), + presenting: state, + actions: { state in + ForEach(state.buttons) { button in + let usesDefaultAction = state.usesDefaultAction(for: button) Button( - role: alertState.buttonRole(for: button), + role: state.buttonRole(for: button), action: { button.withAction { action in if let action { diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift index 4179840f..4934e89d 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift @@ -6,17 +6,24 @@ // import SwiftUI +import Combine import ComposableArchitecture import Core import Domain public struct TodoDetailView: View { + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @State var store: StoreOf + private let windowEvent: TodoEditorWindowEvent? - public init(store: StoreOf) { + public init( + store: StoreOf, + windowEvent: TodoEditorWindowEvent? = nil + ) { self.store = store + self.windowEvent = windowEvent } public var body: some View { @@ -35,19 +42,32 @@ public struct TodoDetailView: View { } } .onAppear { store.send(.onAppear) } + .onReceive(windowSubmits) { submit in + guard case .update(let value, let todo) = submit, + value.matchesEdit(todoId: store.todoId) else { return } + store.send(.setTodo(todo)) + } .navigationBarTitleDisplayMode(.inline) .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { store in + .sheet( + item: $store.scope(state: \.sheet, action: \.sheet) + .activePresentation(when: isTabContentActive) + ) { store in sheetContent(store) } .fullScreenCover( item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + .activePresentation(when: isTabContentActive) ) { store in fullScreenCoverContent(store) } .toolbar { toolbarContent } } + private var windowSubmits: AnyPublisher { + windowEvent?.submits ?? Empty().eraseToAnyPublisher() + } + @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 4abf64bb..82256726 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -12,6 +12,7 @@ import Domain public struct TodoEditorView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @State var store: StoreOf @FocusState private var field: Field? @@ -45,7 +46,10 @@ public struct TodoEditorView: View { .navigationTitle(store.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) - .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { store in + .sheet( + item: $store.scope(state: \.sheet, action: \.sheet) + .activePresentation(when: isTabContentActive) + ) { store in sheetContent(store) } .toolbar { toolbarContent } @@ -445,6 +449,7 @@ private struct TodoEditorInfoSheetView: View { } private struct DueDatePicker: View { + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.safeAreaInsets) private var safeAreaInsets @State private var isPresented: Bool = false @State private var height: CGFloat = .pi @@ -465,7 +470,7 @@ private struct DueDatePicker: View { } label: { content() } - .sheet(isPresented: $isPresented) { + .sheet(isPresented: $isPresented.activePresentation(when: isTabContentActive)) { DatePicker( "", selection: $dueDate, diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature.swift index 3d1686c6..c4007cd4 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature.swift @@ -89,6 +89,7 @@ public struct TodoListFeature { case tapTogglePinned(TodoListItem) case undoDelete case onAppear + case windowTodoCreated case loadNextPage } @@ -322,6 +323,11 @@ private extension TodoListFeature { return fetchEffect(query: state.query, cursor: nil, showsIndicator: false) case .onAppear: return fetchEffect(query: state.query, cursor: nil) + case .windowTodoCreated: + return .merge( + trackTodoCreateEffect(), + fetchEffect(query: state.query, cursor: nil, showsIndicator: false) + ) case .swipeTodo(let todo): return swipeTodoEffect(todo, state: &state) case .resetFilters: diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift index 89bb3945..cb5fd48b 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -6,11 +6,13 @@ // import SwiftUI +import Combine import ComposableArchitecture import Core import Domain public struct TodoListView: View { + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @@ -18,13 +20,16 @@ public struct TodoListView: View { @State private var headerOffset: CGFloat = .zero @State private var isScrollTrackingEnabled = false @State var store: StoreOf + private let windowEvent: TodoEditorWindowEvent? private let onSelectTodo: (String) -> Void public init( store: StoreOf, + windowEvent: TodoEditorWindowEvent? = nil, onSelectTodo: @escaping (String) -> Void = { _ in } ) { self.store = store + self.windowEvent = windowEvent self.onSelectTodo = onSelectTodo } @@ -58,9 +63,15 @@ public struct TodoListView: View { } } .prominentAlert(store, state: \.alert, action: \.alert) + .onReceive(windowSubmits) { submit in + guard case .create(let value) = submit, + value.matchesCreate(category: store.category, source: .list) else { return } + store.send(.view(.windowTodoCreated)) + } .navigationTitle(TodoCategoryItem(from: store.category).localizedName) .fullScreenCover( item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + .activePresentation(when: isTabContentActive) ) { coverStore in fullScreenCoverContent(coverStore) } @@ -90,6 +101,10 @@ public struct TodoListView: View { .task { store.send(.view(.onAppear)) } } + private var windowSubmits: AnyPublisher { + windowEvent?.submits ?? Empty().eraseToAnyPublisher() + } + @ViewBuilder private var todoListContent: some View { let visibleTodos = store.state.todos.filter { !$0.isHidden } diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift index adde2d64..78700732 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileDependencyPreparation.swift @@ -33,6 +33,13 @@ public enum ProfileDependencyPreparation { dependencies.profileUpdateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase } + public static func prepareRecentTodos( + _ dependencies: inout DependencyValues, + todoMutationEventBus: TodoMutationEventBus + ) { + dependencies.profileTodoMutationEventBus = todoMutationEventBus + } + public static func prepareSettingsSession( _ dependencies: inout DependencyValues, deleteAuthUseCase: DeleteAuthUseCase, diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift index 87ce0f95..e7619a69 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+Dependencies.swift @@ -24,6 +24,11 @@ extension DependencyValues { set { self[ProfileFetchTodosKey.self] = newValue } } + var profileTodoMutationEventBus: TodoMutationEventBus { + get { self[ProfileTodoMutationEventBusKey.self] } + set { self[ProfileTodoMutationEventBusKey.self] = newValue } + } + var profileUpsertStatusMessageUseCase: UpsertStatusMessageUseCase { get { self[ProfileUpsertStatusMessageKey.self] } set { self[ProfileUpsertStatusMessageKey.self] = newValue } @@ -70,6 +75,12 @@ private enum ProfileFetchTodosKey: DependencyKey { } } +private enum ProfileTodoMutationEventBusKey: DependencyKey { + static var liveValue: TodoMutationEventBus { + preconditionFailure("TodoMutationEventBus must be provided.") + } +} + private enum ProfileUpsertStatusMessageKey: DependencyKey { static var liveValue: UpsertStatusMessageUseCase { preconditionFailure("UpsertStatusMessageUseCase must be provided.") diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+RecentTodos.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+RecentTodos.swift new file mode 100644 index 00000000..3ca835a7 --- /dev/null +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+RecentTodos.swift @@ -0,0 +1,88 @@ +// +// ProfileFeature+RecentTodos.swift +// ProfileTab +// +// Created by opfic on 9/10/26. +// + +import Combine +import Core +import Domain +import Foundation +import PresentationShared + +private enum RecentTodoCancelID: Hashable { + case todoMutation +} + +extension ProfileFeature { + func observeTodoMutationEffect() -> Effect { + .publisher { [todoMutationEventBus] in + todoMutationEventBus.observe() + .receive(on: DispatchQueue.main) + .map { _ in .refreshRecentTodos } + } + .cancellable(id: RecentTodoCancelID.todoMutation, cancelInFlight: true) + } + + func fetchRecentTodosEffect() -> Effect { + .run { [fetchPreferencesUseCase, fetchTodosUseCase] send in + await send(.loading(.begin(target: LoadingTarget.recentTodos.target, mode: .immediate))) + async let preferences = try? fetchPreferencesUseCase.execute() + do { + let page = try await fetchRecentTodos(fetchTodosUseCase: fetchTodosUseCase) + let items = page.items + .filter { $0.createdAt != $0.updatedAt } + .prefix(5) + .compactMap(RecentTodoItem.init(from:)) + let recentTodos = Array(items) + if let categoryPreferences = await preferences { + let categories = categoryPreferences.map(TodoCategoryItem.init(from:)) + await send( + .store( + .updateRecentTodos( + Self.syncRecentTodos(recentTodos, preferences: categories) + ) + ) + ) + } else { + await send(.store(.updateRecentTodos(recentTodos))) + await send(.setAlert(true)) + } + } catch { + await send(.setAlert(true)) + } + await send(.loading(.end(target: LoadingTarget.recentTodos.target, mode: .immediate))) + } + } +} + +private extension ProfileFeature { + func fetchRecentTodos(fetchTodosUseCase: FetchTodosUseCase) async throws -> TodoPage { + try await fetchTodosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100 + ), + cursor: nil + ) + } + + 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 + } + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift index 3a600d8e..a9c9dd9b 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+State.swift @@ -11,7 +11,11 @@ import PresentationShared extension ProfileFeature.State { var isLoading: Bool { - loading.isLoading + loading.visibleTargets.contains(.default) + } + + var isRecentTodosLoading: Bool { + loading.visibleTargets.contains(ProfileFeature.LoadingTarget.recentTodos.target) } var quarterTitle: String { diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift index be8c0502..8ce94133 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature.swift @@ -17,6 +17,17 @@ struct ProfileFeature { case networkConnectivity } + enum LoadingTarget: Hashable { + case recentTodos + + var target: LoadingFeature.Target { + switch self { + case .recentTodos: + return LoadingFeature.Target("profile.recentTodos") + } + } + } + @ObservableState struct State: Equatable { @Presents var alert: AlertState? @@ -26,6 +37,7 @@ struct ProfileFeature { var statusMessage = "" var avatarURL: URL? var avatarImageData: ProfileAvatarImageData? + var recentTodos = [RecentTodoItem]() var earliestQuarterStart: Date? var selectedQuarterStart: Date? var showQuarterPicker = false @@ -44,6 +56,7 @@ struct ProfileFeature { case startObserving case fetchData case refresh + case refreshRecentTodos case networkStatusChanged(Bool) case setAlert(Bool) case tapResetStatusMessageButton @@ -66,12 +79,15 @@ struct ProfileFeature { quarter: HeatmapQuarter, dayActivitiesByDate: [Date: [HeatmapActivityItem]] ) + case updateRecentTodos([RecentTodoItem]) } } @Dependency(\.profileFetchUserDataUseCase) var fetchUserDataUseCase @Dependency(\.profileFetchImageDataUseCase) var fetchProfileImageDataUseCase @Dependency(\.profileFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase + @Dependency(\.profileTodoMutationEventBus) var todoMutationEventBus @Dependency(\.profileUpsertStatusMessageUseCase) var upsertStatusMessageUseCase @Dependency(\.profileNetworkConnectivityUseCase) var networkConnectivityUseCase @Dependency(\.profileFetchHeatmapActivityTypesUseCase) var fetchHeatmapActivityTypesUseCase @@ -97,7 +113,10 @@ struct ProfileFeature { case .binding: break case .startObserving: - return observeNetworkConnectivityEffect() + return .merge( + observeNetworkConnectivityEffect(), + observeTodoMutationEffect() + ) case .fetchData, .refresh: if state.selectedQuarterStart == nil, let quarterStart = ProfileHeatmapBuilder.quarterStart(for: Date()) { @@ -112,10 +131,16 @@ struct ProfileFeature { if let selectedQuarterStart = state.selectedQuarterStart { return .merge( fetchUserDataEffect(), - fetchActivityQuarterEffect(selectedQuarterStart, showsIndicator: showsIndicator) + fetchActivityQuarterEffect(selectedQuarterStart, showsIndicator: showsIndicator), + fetchRecentTodosEffect() ) } - return fetchUserDataEffect() + return .merge( + fetchUserDataEffect(), + fetchRecentTodosEffect() + ) + case .refreshRecentTodos: + return fetchRecentTodosEffect() case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected case .setAlert(let isPresented): @@ -182,6 +207,8 @@ struct ProfileFeature { guard state.selectedQuarterStart == quarterStart else { break } state.activityQuarter = quarter state.dayActivitiesByDate = dayActivitiesByDate + case .store(.updateRecentTodos(let todos)): + state.recentTodos = todos case .loading: break } @@ -310,4 +337,5 @@ private extension ProfileFeature { TextState(String(localized: "common_error_message", bundle: PresentationResources.bundle)) } } + } diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileRegularDetailView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileRegularDetailView.swift deleted file mode 100644 index afa295af..00000000 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileRegularDetailView.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// ProfileRegularDetailView.swift -// ProfileTab -// -// Created by opfic on 7/6/26. -// - -import SwiftUI -import PresentationShared - -public struct ProfileRegularDetailView: View { - let coordinator: ProfileViewCoordinator - - public init(coordinator: ProfileViewCoordinator) { - self.coordinator = coordinator - } - - public var body: some View { - NavigationStack(path: navigationPath) { - Group { - if let route = coordinator.router.root { - ProfileDestinationView( - route: route, - coordinator: coordinator, - identifiesActivityDetail: true - ) - } else { - ContentUnavailableView( - String(localized: "profile_select_detail", bundle: PresentationResources.bundle), - systemImage: "person.crop.circle" - ) - } - } - .navigationDestination(for: ProfileRoute.self) { route in - ProfileDestinationView( - route: route, - coordinator: coordinator, - identifiesActivityDetail: true - ) - } - } - .background(Color(.systemGroupedBackground).ignoresSafeArea()) - } - - private var navigationPath: Binding<[ProfileRoute]> { - Binding( - get: { coordinator.router.detailPath }, - set: { coordinator.router.detailPath = $0 } - ) - } -} - -struct ProfileDestinationView: View { - let route: ProfileRoute - let coordinator: ProfileViewCoordinator - let identifiesActivityDetail: Bool - - init( - route: ProfileRoute, - coordinator: ProfileViewCoordinator, - identifiesActivityDetail: Bool = false - ) { - self.route = route - self.coordinator = coordinator - self.identifiesActivityDetail = identifiesActivityDetail - } - - var body: some View { - switch route { - case .settings: - SettingsView(store: coordinator.settingsStore) - .environment(coordinator.router) - case .activity(let todoId): - activityDetailView(todoId: todoId) - case .theme: - @Bindable var settingsStore = coordinator.settingsStore - ThemeView(theme: $settingsStore.theme) - case .pushNotification: - PushNotificationSettingsView(store: coordinator.makePushNotificationSettingsStore()) - case .account: - AccountView(store: coordinator.makeAccountStore()) - } - } - - @ViewBuilder - private func activityDetailView(todoId: String) -> some View { - if identifiesActivityDetail { - TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: todoId)) - .id(todoId) - } else { - TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: todoId)) - } - } -} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index 38a92020..95996a27 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -12,38 +12,60 @@ import Domain import PresentationShared public struct ProfileView: View { - @Bindable var store: StoreOf - @FocusState private var focused: Bool - let coordinator: ProfileViewCoordinator - let isCompactLayout: Bool + @State private var settingsStore: StoreOf + @State private var store: StoreOf + @State private var path = [ProfileRoute]() + private let isSelected: Bool + private let windowEvent: TodoEditorWindowEvent public init( - coordinator: ProfileViewCoordinator, - isCompactLayout: Bool + isSelected: Bool, + windowEvent: TodoEditorWindowEvent ) { - self.store = coordinator.store - self.coordinator = coordinator - self.isCompactLayout = isCompactLayout + let store = Store(initialState: ProfileFeature.State()) { + ProfileFeature() + } + let settingsStore = Store(initialState: SettingsFeature.State()) { + SettingsFeature() + } + self._store = State(initialValue: store) + self._settingsStore = State(initialValue: settingsStore) + self.isSelected = isSelected + self.windowEvent = windowEvent } public var body: some View { - Group { - if isCompactLayout { - NavigationStack(path: navigationPath) { - profileContentView - .navigationDestination(for: ProfileRoute.self) { route in - ProfileDestinationView(route: route, coordinator: coordinator) + NavigationStack(path: $path) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { + Section { + ProfileCard(store: store, isSelected: isSelected) + RecentActivityCard(store: store) { todoId in + path.append(.recentTodo(todoId)) } + } header: { titleBar } } - } else { - profileContentView + .padding(.horizontal, 16) + } + .refreshable { await store.send(.refresh).finish() } + .toolbarVisibility(.hidden, for: .navigationBar) + .frame(maxWidth: .infinity) + .background(Color(asset: .appBackground)) + .navigationDestination(for: ProfileRoute.self, destination: destinationView) + } + .onChange(of: isSelected, initial: true) { _, isSelected in + if isSelected { + store.send(.fetchData) } } - .onChange(of: focused) { _, newValue in - store.send(.updateStatusTextFieldFocus(newValue), animation: .default) + .onAppear { + store.send(.startObserving) + settingsStore.send(.startObserving) } .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(isPresented: $store.showQuarterPicker) { quarterPickerSheet } + .sheet( + isPresented: $store.showQuarterPicker.activePresentation(when: isSelected) + ) { quarterPickerSheet } .overlay { if store.isLoading { LoadingView() @@ -51,93 +73,56 @@ public struct ProfileView: View { } } - private var profileContentView: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - profileHeader - statusMessageSection - activityHeatmapSection - } - .padding(.horizontal, 16) - } - .refreshable { await store.send(.refresh).finish() } - .frame(maxWidth: .infinity) - .background(Color(.systemGroupedBackground)) - .toolbar { toolbar } - } - - private var profileHeader: some View { - HStack { - Group { - if let data = store.avatarImageData?.data, - let uiImage = UIImage(data: data) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - .scaledToFill() - .foregroundStyle(Color(.systemGray2)) - } - } - .frame(width: 60, height: 60) - .cornerRadius(30) - .transaction { $0.animation = nil } - - VStack(alignment: .leading) { - Text(store.name) - .font(.title2) - .bold() - Text(store.email) - .font(.caption2) - .foregroundStyle(Color.gray) - } - } - } - - private var statusMessageSection: some View { - let connected = store.isNetworkConnected - - return HStack { + private var titleBar: some View { + VStack(alignment: .leading) { HStack { - Image(systemName: "face.smiling") - TextField( - text: $store.statusMessage - ) { - Text(String(localized: "profile_status_placeholder", bundle: PresentationResources.bundle)) - } - .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) - .focused($focused) - .disabled(!connected) - - if !store.statusMessage.isEmpty, - store.showDoneButton { - Button { - store.send(.tapResetStatusMessageButton) - } label: { - Image(systemName: "xmark.circle.fill") - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .foregroundStyle(Color.gray) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(.secondarySystemGroupedBackground)) - ) - if store.showDoneButton { + Text("프로필") + .font(.largeTitle.bold()) + Spacer() Button { - focused = false - store.send(.willUpdateStatusMessage) + path.append(.settings) } label: { - Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) + Image(systemName: "gearshape") + .foregroundStyle(Color(asset: .textTertiary)) } - .transition(.move(edge: .trailing).combined(with: .opacity)) + .adaptiveButtonStyle() } + Text("꾸준히 쌓아온 개발 기록을 확인하세요") + .foregroundStyle(Color(asset: .textSecondary)) + .font(.caption) + } + } + + @ViewBuilder + private func destinationView(_ route: ProfileRoute) -> some View { + switch route { + case .settings: + SettingsView(store: settingsStore) { path.append($0) } + case .activity(let todoId): + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: false) + ) { + TodoDetailFeature() + }) + case .recentTodo(let todoId): + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: true) + ) { + TodoDetailFeature() + }, windowEvent: windowEvent) + case .theme: + ThemeView(theme: $settingsStore.theme) + case .pushNotification: + PushNotificationSettingsView(store: Store( + initialState: PushNotificationSettingsFeature.State() + ) { + PushNotificationSettingsFeature() + }) + case .account: + AccountView(store: Store(initialState: AccountFeature.State()) { + AccountFeature() + }) } - .opacity(connected ? 1 : 0.7) } private var activityHeatmapSection: some View { @@ -227,21 +212,6 @@ public struct ProfileView: View { ) } - @ToolbarContentBuilder - private var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - if isCompactLayout { - coordinator.router.push(.settings) - } else { - coordinator.router.replace(with: .settings) - } - } label: { - Image(systemName: "gearshape") - } - } - } - private var quarterPickerSheet: some View { NavigationStack { VStack(alignment: .leading, spacing: 20) { @@ -375,20 +345,218 @@ public struct ProfileView: View { .padding(.top, 4) } - private var navigationPath: Binding<[ProfileRoute]> { - Binding( - get: { coordinator.router.path }, - set: { coordinator.router.path = $0 } - ) - } - private func selectActivity(_ activity: HeatmapActivityItem) { guard !activity.isDeleted else { return } + path.append(.activity(activity.todoId)) + } +} + +private struct ProfileCard: View { + @Bindable var store: StoreOf + @FocusState private var focused: Bool + let isSelected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Group { + if let data = store.avatarImageData?.data, + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFill() + .symbolRenderingMode(.palette) + .foregroundStyle(Color(asset: .onPrimaryContainer), Color(asset: .primaryContainer)) + } + } + .frame(width: 60, height: 60) + .cornerRadius(30) + .transaction { $0.animation = nil } + + VStack(alignment: .leading) { + Text(store.name) + .font(.title2) + .bold() + Text(store.email) + .font(.caption2) + .foregroundStyle(Color.gray) + } + } - if isCompactLayout { - coordinator.router.push(.activity(activity.todoId)) - } else { - coordinator.router.replace(with: .activity(activity.todoId)) + HStack { + HStack { + Image(systemName: "face.smiling") + TextField( + text: $store.statusMessage + ) { + Text(String(localized: "profile_status_placeholder", bundle: PresentationResources.bundle)) + } + .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) + .focused($focused) + .disabled(!store.isNetworkConnected) + + if !store.statusMessage.isEmpty, + store.showDoneButton { + Button { + store.send(.tapResetStatusMessageButton) + } label: { + Image(systemName: "xmark.circle.fill") + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .foregroundStyle(Color.gray) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(asset: .primaryContainer)) + ) + if store.showDoneButton { + Button { + focused = false + store.send(.willUpdateStatusMessage) + } label: { + Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .opacity(store.isNetworkConnected ? 1 : 0.7) + } + .onChange(of: isSelected, initial: true) { _, isSelected in + if !isSelected { + focused = false + } + } + .onChange(of: focused) { _, focused in + store.send(.updateStatusTextFieldFocus(focused), animation: .default) + } + } +} + +// 개발 활동 카드 +private struct DevActivityCard: View { + @Bindable var store: StoreOf + + var body: some View { + + } +} + +// 개발 목표 카드 +private struct DevAchieveMentCard: View { + @Bindable var store: StoreOf + + var body: some View { + + } +} + +// 최근 활동 카드 +private struct RecentActivityCard: View { + @Bindable var store: StoreOf + @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) + let onSelectTodo: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "profile_recent_title", bundle: PresentationResources.bundle)) + .font(.title2.bold()) + + Group { + if store.isRecentTodosLoading && store.recentTodos.isEmpty { + LoadingView() + .frame(maxWidth: .infinity, minHeight: 80) + } else if store.recentTodos.isEmpty { + Text(String(localized: "profile_recent_empty", bundle: PresentationResources.bundle)) + .font(.callout) + .foregroundStyle(Color(asset: .textSecondary)) + .frame(maxWidth: .infinity, minHeight: 80) + } else { + VStack(spacing: 0) { + ForEach(Array(store.recentTodos.enumerated()), id: \.element.id) { index, todo in + Button { + onSelectTodo(todo.id) + } label: { + HStack(spacing: 12) { + RecentTodoRow(todo: todo) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(asset: .textTertiary)) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .todoDetailPreview(todoId: todo.id) + .padding(.vertical, 12) + + if index < store.recentTodos.count - 1 { + Divider() + .padding(.leading, labelWidth + 12) + } + } + } + } + } + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(.secondarySystemGroupedBackground)) + ) + } + } +} + +private struct RecentTodoRow: View { + @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) + let todo: RecentTodoItem + + var body: some View { + let category = TodoCategoryItem(from: todo.category) + HStack(alignment: .top, spacing: 12) { + RoundedRectangle(cornerRadius: 8) + .fill(category.color) + .frame(width: labelWidth, height: labelWidth) + .overlay { + Image(systemName: category.symbolName) + .foregroundStyle(Color.white) + .font(.title3) + } + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + if todo.isPinned { + Image(systemName: "star.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + } + Text(todo.title) + .foregroundStyle(Color.primary) + .font(.headline) + .lineLimit(1) + Text("#\(todo.number)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.gray) + .fixedSize(horizontal: true, vertical: false) + } + + HStack(spacing: 6) { + Text(category.localizedName) + .font(.caption.weight(.semibold)) + .foregroundStyle(category.color) + + RelativeTimeText(date: todo.updatedAt) + } + + if !todo.tags.isEmpty { + TagList(todo.tags, lineLimit: 1) + } + } } } } @@ -396,6 +564,7 @@ public struct ProfileView: View { enum ProfileRoute: Hashable { case settings case activity(String) + case recentTodo(String) case theme case pushNotification case account diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileViewCoordinator.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileViewCoordinator.swift deleted file mode 100644 index 99741a82..00000000 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileViewCoordinator.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// ProfileViewCoordinator.swift -// ProfileTab -// -// Created by opfic on 5/21/26. -// - -import Foundation -import Domain -import PresentationShared - -@MainActor -@Observable -public final class ProfileViewCoordinator { - let store: StoreOf - let settingsStore: StoreOf - var router = NavigationRouter() - - public init() { - self.store = Store(initialState: ProfileFeature.State()) { - ProfileFeature() - } - self.settingsStore = Store(initialState: SettingsFeature.State()) { - SettingsFeature() - } - self.store.send(.startObserving) - self.settingsStore.send(.startObserving) - } - - public func fetchData() { - store.send(.fetchData) - } - - func makeAccountStore() -> StoreOf { - Store(initialState: AccountFeature.State()) { - AccountFeature() - } - } - - func makePushNotificationSettingsStore() -> StoreOf { - Store(initialState: PushNotificationSettingsFeature.State()) { - PushNotificationSettingsFeature() - } - } - - func makeTodoDetailStore(todoId: String) -> StoreOf { - Store( - initialState: TodoDetailFeature.State( - todoId: todoId, - showEditButton: false - ) - ) { - TodoDetailFeature() - } - } -} diff --git a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift index 6b213450..60746640 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift @@ -9,6 +9,7 @@ import SwiftUI import PresentationShared struct PushNotificationSettingsView: View { + @Environment(\.isTabContentActive) private var isTabContentActive @State var store: StoreOf var body: some View { @@ -71,7 +72,10 @@ struct PushNotificationSettingsView: View { .navigationTitle(String(localized: "nav_push_settings", bundle: PresentationResources.bundle)) .onAppear { store.send(.fetchSettings) } .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(item: $store.scope(state: \.timePicker, action: \.timePicker)) { timePickerStore in + .sheet( + item: $store.scope(state: \.timePicker, action: \.timePicker) + .activePresentation(when: isTabContentActive) + ) { timePickerStore in TimePickerView( store: timePickerStore, showsProgressView: store.isLoading && store.activeLoadingRow == .customTime diff --git a/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift index 0e53421b..714abfa8 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/SettingsView.swift @@ -10,15 +10,15 @@ import Domain import PresentationShared struct SettingsView: View { - @Environment(NavigationRouter.self) private var router @Bindable var store: StoreOf + let onNavigate: (ProfileRoute) -> Void var body: some View { let connected = store.isNetworkConnected Form { Section { Button { - router.push(.theme) + onNavigate(.theme) } label: { HStack { Text(String(localized: "settings_theme", bundle: PresentationResources.bundle)) @@ -30,7 +30,7 @@ struct SettingsView: View { } Button { - router.push(.pushNotification) + onNavigate(.pushNotification) } label: { Text(String(localized: "settings_notifications", bundle: PresentationResources.bundle)) .foregroundStyle(connected ? Color.primary : Color.secondary) @@ -87,7 +87,7 @@ struct SettingsView: View { Section { Button { - router.push(.account) + onNavigate(.account) } label: { Text(String(localized: "settings_account", bundle: PresentationResources.bundle)) } diff --git a/Application/Presentation/ProfileTab/Sources/Structure/Profile/ActivityKindItem.swift b/Application/Presentation/ProfileTab/Sources/Structure/ActivityKindItem.swift similarity index 100% rename from Application/Presentation/ProfileTab/Sources/Structure/Profile/ActivityKindItem.swift rename to Application/Presentation/ProfileTab/Sources/Structure/ActivityKindItem.swift diff --git a/Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapActivityItem.swift b/Application/Presentation/ProfileTab/Sources/Structure/HeatmapActivityItem.swift similarity index 100% rename from Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapActivityItem.swift rename to Application/Presentation/ProfileTab/Sources/Structure/HeatmapActivityItem.swift diff --git a/Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapDay.swift b/Application/Presentation/ProfileTab/Sources/Structure/HeatmapDay.swift similarity index 100% rename from Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapDay.swift rename to Application/Presentation/ProfileTab/Sources/Structure/HeatmapDay.swift diff --git a/Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapMonth.swift b/Application/Presentation/ProfileTab/Sources/Structure/HeatmapMonth.swift similarity index 100% rename from Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapMonth.swift rename to Application/Presentation/ProfileTab/Sources/Structure/HeatmapMonth.swift diff --git a/Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapQuarter.swift b/Application/Presentation/ProfileTab/Sources/Structure/HeatmapQuarter.swift similarity index 100% rename from Application/Presentation/ProfileTab/Sources/Structure/Profile/HeatmapQuarter.swift rename to Application/Presentation/ProfileTab/Sources/Structure/HeatmapQuarter.swift diff --git a/Application/Presentation/ProfileTab/Sources/Structure/Profile/ProfileAvatarImageData.swift b/Application/Presentation/ProfileTab/Sources/Structure/ProfileAvatarImageData.swift similarity index 100% rename from Application/Presentation/ProfileTab/Sources/Structure/Profile/ProfileAvatarImageData.swift rename to Application/Presentation/ProfileTab/Sources/Structure/ProfileAvatarImageData.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Structure/Todo/RecentTodoItem.swift b/Application/Presentation/ProfileTab/Sources/Structure/RecentTodoItem.swift similarity index 92% rename from Application/Presentation/HomeTab/Sources/Home/Structure/Todo/RecentTodoItem.swift rename to Application/Presentation/ProfileTab/Sources/Structure/RecentTodoItem.swift index d2b0449d..769e76a0 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Structure/Todo/RecentTodoItem.swift +++ b/Application/Presentation/ProfileTab/Sources/Structure/RecentTodoItem.swift @@ -1,8 +1,8 @@ // // RecentTodoItem.swift -// HomeTab +// ProfileTab // -// Created by opfic on 3/6/26. +// Created by opfic on 9/10/26. // import Foundation diff --git a/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift index 93b30288..b2c0e521 100644 --- a/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Profile/ProfileFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import Combine import PresentationShared import Foundation import Core @@ -14,6 +15,93 @@ import Domain @MainActor struct ProfileFeatureTests { + @Test("ProfileFeature는 최근 수정한 Todo를 최대 5개까지 카테고리 설정과 함께 갱신한다") + func ProfileFeature는_최근_수정한_Todo를_최대_5개까지_카테고리_설정과_함께_갱신한다() async { + let category = TodoCategory.user( + UserTodoCategory(id: "custom", name: "Before", colorHex: "#111111") + ) + let updatedCategory = TodoCategory.user( + UserTodoCategory(id: "custom", name: "After", colorHex: "#222222") + ) + let fetchSpy = FetchTodosUseCaseSpy() + let unchangedDate = Date(timeIntervalSince1970: 0) + fetchSpy.todoPage = TodoPage( + items: [ + makeProfileTodo(id: "unchanged", createdAt: unchangedDate, updatedAt: unchangedDate), + makeProfileTodo(id: "todo-1", category: category), + makeProfileTodo(id: "todo-2"), + makeProfileTodo(id: "todo-3"), + makeProfileTodo(id: "todo-4"), + makeProfileTodo(id: "todo-5"), + makeProfileTodo(id: "todo-6") + ], + nextCursor: nil + ) + let preferencesSpy = FetchTodoCategoryPreferencesUseCaseSpy() + preferencesSpy.preferences = [ + TodoCategoryPreference(category: updatedCategory, isVisible: true) + ] + let adapter = ProfileStoreTestAdapter( + fetchTodosUseCase: fetchSpy, + fetchPreferencesUseCase: preferencesSpy + ) + + await adapter.refreshRecentTodos() + + #expect(adapter.recentTodos.map(\.id) == ["todo-1", "todo-2", "todo-3", "todo-4", "todo-5"]) + #expect(adapter.recentTodos.first?.category == updatedCategory) + #expect(fetchSpy.queries.count == 1) + #expect(fetchSpy.queries.first?.sortTarget == .updatedAt) + #expect(fetchSpy.queries.first?.sortOrder == .latest) + #expect(fetchSpy.queries.first?.pageSize == 100) + } + + @Test("ProfileFeature는 카테고리 설정 조회가 실패해도 최근 Todo를 갱신한다") + func ProfileFeature는_카테고리_설정_조회가_실패해도_최근_Todo를_갱신한다() async { + let fetchSpy = FetchTodosUseCaseSpy() + fetchSpy.todoPage = TodoPage( + items: [makeProfileTodo(id: "todo")], + nextCursor: nil + ) + let preferencesSpy = FetchTodoCategoryPreferencesUseCaseSpy() + preferencesSpy.error = TestError() + let adapter = ProfileStoreTestAdapter( + fetchTodosUseCase: fetchSpy, + fetchPreferencesUseCase: preferencesSpy + ) + + await adapter.refreshRecentTodos() + + #expect(adapter.recentTodos.map(\.id) == ["todo"]) + #expect(adapter.isAlertPresented) + } + + @Test("ProfileFeature는 Todo 변경 시 최근 목록만 다시 조회한다") + func ProfileFeature는_Todo_변경_시_최근_목록만_다시_조회한다() async { + let fetchSpy = FetchTodosUseCaseSpy() + let eventBus = TodoMutationEventBusSpy() + let adapter = ProfileStoreTestAdapter( + fetchTodosUseCase: fetchSpy, + todoMutationEventBus: eventBus + ) + + await adapter.startObserving() + await adapter.publishTodoMutation(.updated("todo")) + + #expect(fetchSpy.queries.count == 1) + #expect(fetchSpy.queries.first?.sortTarget == .updatedAt) + } + + @Test("ProfileFeature는 최근 Todo 로딩을 전체 화면 로딩에서 제외한다") + func ProfileFeature는_최근_Todo_로딩을_전체_화면_로딩에서_제외한다() async { + let adapter = ProfileStoreTestAdapter() + + await adapter.beginRecentTodosLoading() + + #expect(adapter.isRecentTodosLoading) + #expect(!adapter.isLoading) + } + @Test("ProfileFeature는 같은 아바타 URL을 다시 받아도 프로필 이미지 데이터를 다시 요청한다") func ProfileFeature는_같은_아바타_URL을_다시_받아도_프로필_이미지_데이터를_다시_요청한다() async { let imageData = Data([1, 2, 3]) @@ -74,6 +162,30 @@ private final class FetchTodosUseCaseSpy: FetchTodosUseCase { } } +private final class FetchTodoCategoryPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { + var error: Error? + var preferences = [TodoCategoryPreference]() + + func execute() async throws -> [TodoCategoryPreference] { + if let error { + throw error + } + return preferences + } +} + +private final class TodoMutationEventBusSpy: TodoMutationEventBus { + private let subject = PassthroughSubject() + + func publish(_ event: TodoMutationEvent) { + subject.send(event) + } + + func observe() -> AnyPublisher { + subject.eraseToAnyPublisher() + } +} + private final class FetchUserDataUseCaseSpy: FetchUserDataUseCase { var profile: UserProfile @@ -127,16 +239,25 @@ private final class UpdateHeatmapActivityTypesUseCaseSpy: UpdateHeatmapActivityT @MainActor private struct ProfileStoreTestAdapter { private let store: TestStoreOf + private let todoMutationEventBus: TodoMutationEventBus var avatarImageData: ProfileAvatarImageData? { store.state.avatarImageData } + var isAlertPresented: Bool { store.state.alert != nil } + var isLoading: Bool { store.state.isLoading } + var isRecentTodosLoading: Bool { store.state.isRecentTodosLoading } + var recentTodos: [RecentTodoItem] { store.state.recentTodos } var selectedActivityKinds: Set { store.state.selectedActivityKinds } init( fetchProfileImageDataUseCase: FetchProfileImageDataUseCase = FetchProfileImageDataUseCaseSpy(data: Data()), + fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), + todoMutationEventBus: TodoMutationEventBus = TodoMutationEventBusSpy(), upsertStatusMessageUseCase: UpsertStatusMessageUseCase = UpsertStatusMessageUseCaseSpy(), fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase = FetchHeatmapActivityTypesUseCaseSpy(), updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase = UpdateHeatmapActivityTypesUseCaseSpy() ) { + self.todoMutationEventBus = todoMutationEventBus store = TestStore(initialState: ProfileFeature.State()) { ProfileFeature() } withDependencies: { @@ -150,7 +271,9 @@ private struct ProfileStoreTestAdapter { ) ) $0.profileFetchImageDataUseCase = fetchProfileImageDataUseCase - $0.profileFetchTodosUseCase = FetchTodosUseCaseSpy() + $0.profileFetchTodosUseCase = fetchTodosUseCase + $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase + $0.profileTodoMutationEventBus = todoMutationEventBus $0.profileUpsertStatusMessageUseCase = upsertStatusMessageUseCase $0.profileNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy() $0.profileFetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase @@ -165,6 +288,33 @@ private struct ProfileStoreTestAdapter { await drainReceivedActions() } + func startObserving() async { + await store.send(.startObserving) + await Task.yield() + } + + func refreshRecentTodos() async { + await store.send(.refreshRecentTodos) + await drainReceivedActions() + } + + func publishTodoMutation(_ event: TodoMutationEvent) async { + todoMutationEventBus.publish(event) + await drainReceivedActions() + } + + func beginRecentTodosLoading() async { + await store.send( + .loading( + .begin( + target: ProfileFeature.LoadingTarget.recentTodos.target, + mode: .immediate + ) + ) + ) + await drainReceivedActions() + } + func fetchUserData(_ profile: UserProfile) async { await store.send(.store(.fetchUserData(profile))) await drainReceivedActions() @@ -205,3 +355,29 @@ private struct ProfileStoreTestAdapter { } } } + +private func makeProfileTodo( + id: String, + category: TodoCategory = .system(.feature), + createdAt: Date = Date(timeIntervalSince1970: 0), + updatedAt: Date = Date(timeIntervalSince1970: 10) +) -> Todo { + Todo( + id: id, + isPinned: false, + isCompleted: false, + isChecked: false, + number: 1, + title: "Todo", + content: "content", + createdAt: createdAt, + updatedAt: updatedAt, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: category + ) +} + +private struct TestError: Error { } diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayView.swift b/Application/Presentation/TodayTab/Sources/Today/TodayView.swift index fe6fecd7..2c309d6d 100644 --- a/Application/Presentation/TodayTab/Sources/Today/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/Today/TodayView.swift @@ -11,35 +11,51 @@ import Domain import PresentationShared public struct TodayView: View { - @Bindable var store: StoreOf - let coordinator: TodayViewCoordinator - let isCompactLayout: Bool + @State private var path = [TodayRoute]() + @State private var store: StoreOf + private let isSelected: Bool + private let windowEvent: TodoEditorWindowEvent public init( - coordinator: TodayViewCoordinator, - isCompactLayout: Bool + isSelected: Bool, + windowEvent: TodoEditorWindowEvent ) { - self.coordinator = coordinator - self.isCompactLayout = isCompactLayout - self.store = coordinator.store + @Dependency(\.todayFetchDisplayOptionsUseCase) var fetchDisplayOptionsUseCase + self._store = State(initialValue: Store( + initialState: TodayFeature.State( + displayOptions: fetchDisplayOptionsUseCase.execute() + ) + ) { + TodayFeature() + }) + self.isSelected = isSelected + self.windowEvent = windowEvent } public var body: some View { - List { - summarySection - if store.sections.isEmpty, !store.isLoading { - emptySection - } else { - ForEach(store.sections) { section in - todoSection(section.title, items: section.items) + NavigationStack(path: $path) { + List { + summarySection + if store.sections.isEmpty, !store.isLoading { + emptySection + } else { + ForEach(store.sections) { section in + todoSection(section.title, items: section.items) + } } } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nav_today", bundle: PresentationResources.bundle)) + .navigationDestination(for: TodayRoute.self, destination: destinationView) + .toolbar { toolbarContent } + .background(NavigationBarConfigurator()) + .refreshable { await store.send(.refresh).finish() } + } + .onChange(of: isSelected, initial: true) { _, isSelected in + if isSelected { + store.send(.fetchData) + } } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_today", bundle: PresentationResources.bundle)) - .toolbar { toolbarContent } - .background(NavigationBarConfigurator()) - .refreshable { await store.send(.refresh).finish() } .prominentAlert(store, state: \.alert, action: \.alert) .overlay { if store.isLoading { @@ -48,6 +64,21 @@ public struct TodayView: View { } } + private func destinationView(_ route: TodayRoute) -> some View { + switch route { + case .todo(let item): + TodoDetailView( + store: Store( + initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: true) + ) { + TodoDetailFeature() + }, + windowEvent: windowEvent + ) + .id(item.id) + } + } + private var summarySection: some View { Section { ScrollView(.horizontal) { @@ -161,21 +192,8 @@ public struct TodayView: View { @ViewBuilder private func todoRow(_ item: TodayTodoItem) -> some View { - Group { - if isCompactLayout { - NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { - TodayTodoRow(item: item) - } - } else { - Button { - coordinator.router.replace(with: .todo(TodoIdItem(id: item.id))) - } label: { - TodayTodoRow(item: item) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - } + NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { + TodayTodoRow(item: item) } .todoDetailPreview(todoId: item.id) } diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayViewCoordinator.swift b/Application/Presentation/TodayTab/Sources/Today/TodayViewCoordinator.swift deleted file mode 100644 index 3f40c2cb..00000000 --- a/Application/Presentation/TodayTab/Sources/Today/TodayViewCoordinator.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// TodayViewCoordinator.swift -// TodayTab -// -// Created by opfic on 5/10/26. -// - -import Foundation -import Domain -import PresentationShared - -@MainActor -@Observable -public final class TodayViewCoordinator { - let store: StoreOf - public let router = NavigationRouter() - - public init() { - @Dependency(\.todayFetchDisplayOptionsUseCase) var fetchDisplayOptionsUseCase - self.store = Store( - initialState: TodayFeature.State( - displayOptions: fetchDisplayOptionsUseCase.execute() - ) - ) { - TodayFeature() - } - } - - public func fetchData() { - store.send(.fetchData) - } -} diff --git a/Application/Shared/Version.xcconfig b/Application/Shared/Version.xcconfig index 0009a1e3..d33c084f 100644 --- a/Application/Shared/Version.xcconfig +++ b/Application/Shared/Version.xcconfig @@ -1,2 +1,2 @@ MARKETING_VERSION = 1.6 -IPHONEOS_DEPLOYMENT_TARGET = 17.0 +IPHONEOS_DEPLOYMENT_TARGET = 18.0 diff --git a/Libraries/MarkdownRenderer/Project.swift b/Libraries/MarkdownRenderer/Project.swift index 51b038bd..9b4298e2 100644 --- a/Libraries/MarkdownRenderer/Project.swift +++ b/Libraries/MarkdownRenderer/Project.swift @@ -2,7 +2,7 @@ import ProjectDescription import ProjectDescriptionHelpers let deploymentSettings: SettingsDictionary = [ - "IPHONEOS_DEPLOYMENT_TARGET": "17.0", + "IPHONEOS_DEPLOYMENT_TARGET": "18.0", "MARKETING_VERSION": "1.0.0", ] diff --git a/Libraries/MarkdownRenderer/Sources/MarkdownRendererView.swift b/Libraries/MarkdownRenderer/Sources/MarkdownRendererView.swift index fc5f34b8..2d993d67 100644 --- a/Libraries/MarkdownRenderer/Sources/MarkdownRendererView.swift +++ b/Libraries/MarkdownRenderer/Sources/MarkdownRendererView.swift @@ -15,18 +15,15 @@ public struct MarkdownRendererView: View { private let markdown: String private let references: [Int: MarkdownRendererReference] - private let obscuredBottomInset: CGFloat private let onOpenReferenceID: ((String) -> Void)? public init( markdown: String, references: [Int: MarkdownRendererReference] = [:], - obscuredBottomInset: CGFloat = .zero, onOpenReferenceID: ((String) -> Void)? = nil ) { self.markdown = markdown self.references = references - self.obscuredBottomInset = obscuredBottomInset self.onOpenReferenceID = onOpenReferenceID } @@ -37,7 +34,6 @@ public struct MarkdownRendererView: View { colorScheme: colorScheme, languageCode: locale.language.languageCode?.identifier ?? "und", fontSize: fontSize, - obscuredBottomInset: obscuredBottomInset, onOpenReferenceID: onOpenReferenceID, onOpenURL: { openURL($0) } ) diff --git a/Libraries/MarkdownRenderer/Sources/MarkdownWebView.swift b/Libraries/MarkdownRenderer/Sources/MarkdownWebView.swift index 03acd178..47005fed 100644 --- a/Libraries/MarkdownRenderer/Sources/MarkdownWebView.swift +++ b/Libraries/MarkdownRenderer/Sources/MarkdownWebView.swift @@ -14,7 +14,6 @@ struct MarkdownWebView: UIViewRepresentable { let colorScheme: ColorScheme let languageCode: String let fontSize: CGFloat - let obscuredBottomInset: CGFloat var onOpenReferenceID: ((String) -> Void)? var onOpenURL: ((URL) -> Void)? @@ -102,7 +101,6 @@ struct MarkdownWebView: UIViewRepresentable { webView: WKWebView ) { self.view = view - updateObscuredContentInsets(in: webView) pendingPayload = MarkdownRendererBridge.RenderPayload(view: view) renderIfNeeded(in: webView) } @@ -112,14 +110,6 @@ struct MarkdownWebView: UIViewRepresentable { pendingPayload = nil } - private func updateObscuredContentInsets(in webView: WKWebView) { - guard #available(iOS 26.0, *) else { return } - - var insets = webView.obscuredContentInsets - insets.bottom = view.obscuredBottomInset - webView.obscuredContentInsets = insets - } - private func renderIfNeeded(in webView: WKWebView) { guard isRendererLoaded, diff --git a/Libraries/ThirdParty/Project.swift b/Libraries/ThirdParty/Project.swift index af00cf31..1b61b6dc 100644 --- a/Libraries/ThirdParty/Project.swift +++ b/Libraries/ThirdParty/Project.swift @@ -2,7 +2,7 @@ import ProjectDescription import ProjectDescriptionHelpers let deploymentSettings: SettingsDictionary = [ - "IPHONEOS_DEPLOYMENT_TARGET": "17.0", + "IPHONEOS_DEPLOYMENT_TARGET": "18.0", "MARKETING_VERSION": "1.0.0", ]