From 66c2b42b06e26e1ace0b0caf34a4c1afdfa8598e Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 9 Sep 2026 22:11:51 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20Home=20=EC=9B=B9=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 8 - .../Entry/Sources/Main/MainView.swift | 12 -- .../Home/Common/Component/WebItemRow.swift | 73 ------- .../Home/Home/HomeFeature+Dependencies.swift | 60 ------ .../Home/Home/HomeFeature+Effects.swift | 99 +-------- .../Sources/Home/Home/HomeFeature.swift | 162 +-------------- .../HomeTab/Sources/Home/Home/HomeView.swift | 152 +------------- .../Home/HomeDependencyPreparation.swift | 15 -- .../Sources/Home/Search/SearchFeature.swift | 50 +---- .../Sources/Home/Search/SearchView.swift | 51 +---- .../Sources/Home/Structure/WebPageItem.swift | 28 --- .../Home/HomeFeatureTestAssertions.swift | 118 +---------- .../Tests/Home/HomeFeatureTestSpies.swift | 42 ---- .../Tests/Home/HomeFeatureTestSupport.swift | 78 -------- .../HomeTab/Tests/Home/HomeFeatureTests.swift | 109 +--------- .../Search/SearchFeatureTestDoubles.swift | 57 +----- .../Tests/Search/SearchFeatureTests.swift | 35 +--- .../Resources/Localizable.xcstrings | 188 +----------------- .../Sources/Common/WebView.swift | 27 --- 19 files changed, 36 insertions(+), 1328 deletions(-) delete mode 100644 Application/Presentation/HomeTab/Sources/Home/Common/Component/WebItemRow.swift delete mode 100644 Application/Presentation/HomeTab/Sources/Home/Structure/WebPageItem.swift delete mode 100644 Application/Presentation/PresentationShared/Sources/Common/WebView.swift diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index c358cb2f..61467727 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -87,13 +87,6 @@ private extension AppGraph { .updateTodoCategoryPreferencesUseCase, todoMutationEventBus: todoGraphSet.todoMutationEventBusGraph.todoMutationEventBus ) - HomePresentationDependencyPreparation.prepareWebPage( - &dependencies, - addWebPageUseCase: webPageGraphSet.webPageUseCaseGraph.addWebPageUseCase, - deleteWebPageUseCase: webPageGraphSet.webPageUseCaseGraph.deleteWebPageUseCase, - undoDeleteWebPageUseCase: webPageGraphSet.webPageUseCaseGraph.undoDeleteWebPageUseCase, - fetchWebPagesUseCase: webPageGraphSet.webPageUseCaseGraph.fetchWebPagesUseCase - ) HomePresentationDependencyPreparation.prepareTodo( &dependencies, fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, @@ -107,7 +100,6 @@ private extension AppGraph { .userPreferencesUseCaseGraph .fetchRecentSearchQueriesUseCase, fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, - fetchWebPagesUseCase: webPageGraphSet.webPageUseCaseGraph.fetchWebPagesUseCase, updateRecentSearchQueriesUseCase: userPreferencesGraphSet .userPreferencesUseCaseGraph .updateRecentSearchQueriesUseCase diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index 227ed3cc..09f2ebb7 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -235,17 +235,6 @@ struct MainView: View { 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() - } - } } } @@ -381,7 +370,6 @@ private extension MainView { set: { todayViewCoordinator.router.detailPath = $0 } ) } - } private extension MainTab { var title: String { 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..bbc3bdc2 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift @@ -14,31 +14,11 @@ 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,36 +35,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.") @@ -95,16 +45,6 @@ private enum HomeFetchTodosUseCaseKey: DependencyKey { } } -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..721d3843 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift @@ -32,7 +32,7 @@ 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))) } @@ -49,79 +49,24 @@ extension HomeFeature { .compactMap(RecentTodoItem.init(from:)) await send(.store(.updateRecentTodos(Array(items)))) } catch { - await send(.store(.setAlert(isPresented: true, type: .error))) + await send(.store(.setAlert(isPresented: true))) } 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))) } } } @@ -158,7 +103,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,38 +111,25 @@ 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)) } } @@ -218,15 +150,4 @@ extension HomeFeature { } } - 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..0c268a0e 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift @@ -18,15 +18,14 @@ struct HomeFeature { @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 } @@ -42,18 +41,6 @@ struct HomeFeature { 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 { @@ -69,49 +56,26 @@ struct HomeFeature { 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 +87,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 @@ -183,8 +129,6 @@ struct HomeFeature { enum LoadingTarget: Hashable { case preferences case recentTodos - case webPage - case overlay var target: LoadingFeature.Target { switch self { @@ -192,21 +136,13 @@ struct HomeFeature { 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 @@ -278,18 +214,10 @@ private extension HomeFeature { case .fetchData: return .merge( fetchTodoCategoryPreferencesEffect(), - fetchRecentTodosEffect(), - fetchWebPagesEffect() + fetchRecentTodosEffect() ) 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 - } case .todoEditorCreated: state.fullScreenCover = nil state.selectedTodoCategory = nil @@ -303,30 +231,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 @@ -353,26 +257,13 @@ 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 +279,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..979537a3 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift @@ -30,7 +30,6 @@ public struct HomeView: View { List { todoSection recentTodoSection - webPageSection } .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_home", bundle: PresentationResources.bundle)) @@ -97,49 +96,6 @@ public struct HomeView: View { } } - 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 +120,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 +146,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) { @@ -327,34 +224,6 @@ public struct HomeView: View { .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 labelImage( text: String, systemName: String, @@ -388,28 +257,11 @@ 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 { diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift index 15cf5718..4090ec15 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift @@ -18,19 +18,6 @@ public enum HomeDependencyPreparation { 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, @@ -44,12 +31,10 @@ 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 } } 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..be864d27 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift @@ -14,46 +14,21 @@ import PresentationShared @MainActor func verifyHomeFetchData( adapter: HomeStoreTestAdapter, - fetchTodosUseCaseSpy: FetchTodosUseCaseSpy, - fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy + fetchTodosUseCaseSpy: FetchTodosUseCaseSpy ) 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 @@ -100,55 +75,9 @@ func verifyHomeOrderTodoCategory( #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 { @@ -193,14 +122,9 @@ func makeHomeFetchDataContext() -> HomeFetchDataContext { nextCursor: nil ) - let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( - webPages: [makeHomeWebPage()] - ) - return HomeFetchDataContext( fetchPreferencesUseCaseSpy: fetchPreferencesUseCaseSpy, - fetchTodosUseCaseSpy: fetchTodosUseCaseSpy, - fetchWebPagesUseCaseSpy: fetchWebPagesUseCaseSpy + fetchTodosUseCaseSpy: fetchTodosUseCaseSpy ) } @@ -218,41 +142,3 @@ func makeHomeOrderContext() -> HomeOrderContext { fetchTodosUseCaseSpy: fetchContext.fetchTodosUseCaseSpy ) } - -struct HomeAddWebPageContext { - let addWebPageUseCaseSpy: AddWebPageUseCaseSpy - let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy - let trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy -} - -func makeHomeAddWebPageContext() -> HomeAddWebPageContext { - HomeAddWebPageContext( - addWebPageUseCaseSpy: AddWebPageUseCaseSpy(), - fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy( - webPages: [ - makeHomeWebPage(), - makeHomeWebPage( - title: "Apple", - urlString: "https://developer.apple.com" - ) - ] - ), - trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy() - ) -} - -struct HomeDeleteContext { - let addWebPageUseCaseSpy: AddWebPageUseCaseSpy - let fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy - let deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy - let undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy -} - -func makeHomeDeleteContext() -> HomeDeleteContext { - HomeDeleteContext( - addWebPageUseCaseSpy: AddWebPageUseCaseSpy(), - fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy(webPages: [makeHomeWebPage()]), - deleteWebPageUseCaseSpy: DeleteWebPageUseCaseSpy(), - undoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCaseSpy() - ) -} diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift index 308201ec..4cb2ca39 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift @@ -39,34 +39,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] = [] @@ -77,20 +49,6 @@ final class FetchTodosUseCaseSpy: FetchTodosUseCase { } } -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..d73d315d 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift @@ -20,43 +20,17 @@ struct HomeStoreTestAdapter { 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 +42,7 @@ 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 +61,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 +86,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) @@ -202,18 +139,3 @@ func makeHomeTodo( 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..da880269 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 @@ -18,24 +17,15 @@ struct HomeFeatureTests { let context = makeHomeFetchDataContext() let adapter = HomeStoreTestAdapter( fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, - fetchTodosUseCase: context.fetchTodosUseCaseSpy, - fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy + fetchTodosUseCase: context.fetchTodosUseCaseSpy ) try await verifyHomeFetchData( adapter: adapter, - fetchTodosUseCaseSpy: context.fetchTodosUseCaseSpy, - fetchWebPagesUseCaseSpy: context.fetchWebPagesUseCaseSpy + fetchTodosUseCaseSpy: context.fetchTodosUseCaseSpy ) } - @Test("HomeFeature webPageInput은 contentPicker 내부 내비게이션을 표시한다") - func HomeFeature_webPageInput은_contentPicker_내부_내비게이션을_표시한다() async throws { - let adapter = HomeStoreTestAdapter() - - try await verifyHomeWebPageInputAlert(adapter: adapter) - } - @Test("HomeFeature tapTodoCategory는 editor를 지연 표시한다") func HomeFeature_tapTodoCategory는_editor를_지연_표시한다() async throws { let adapter = HomeStoreTestAdapter() @@ -50,7 +40,6 @@ struct HomeFeatureTests { let adapter = HomeStoreTestAdapter( fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, fetchTodosUseCase: context.fetchTodosUseCaseSpy, - fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, trackAnalyticsEventUseCase: trackSpy ) @@ -59,13 +48,11 @@ struct HomeFeatureTests { await waitUntil { context.fetchTodosUseCaseSpy.queries.count == 1 - && context.fetchWebPagesUseCaseSpy.calledQueries == [""] && 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를 동기화하고 저장한다") @@ -83,94 +70,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 +85,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/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index d3ec50e5..399d971e 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -383,57 +383,6 @@ } } }, - "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" : { "extractionState" : "manual", "localizations" : { @@ -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/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) - } -} From 17c52587ad1390ede4f4b38bab00edd2503ea1d5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 9 Sep 2026 23:09:30 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=EB=8B=A8=EC=9D=BC=20=EC=97=B4?= =?UTF-8?q?=20=ED=83=90=EC=83=89=20=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entry/Sources/Main/MainView.swift | 341 ++---------------- .../WindowGroup/TodoWindowCoordinator.swift | 83 ----- .../Home/Home/HomeFeature+Effects.swift | 10 + .../Sources/Home/Home/HomeFeature.swift | 6 +- .../HomeTab/Sources/Home/Home/HomeView.swift | 124 ++++--- .../Home/Home/HomeViewCoordinator.swift | 83 ----- .../Sources/PushNotificationListFeature.swift | 15 +- .../Sources/PushNotificationListView.swift | 66 ++-- .../PushNotificationListViewCoordinator.swift | 67 ---- .../PushNotificationListFeatureTests.swift | 16 +- .../PushNotificationListTestSupport.swift | 4 +- .../Sources/Todo/Detail/TodoDetailView.swift | 17 +- .../Sources/Todo/List/TodoListFeature.swift | 6 + .../Sources/Todo/List/TodoListView.swift | 13 + .../Profile/ProfileRegularDetailView.swift | 94 ----- .../Sources/Profile/ProfileView.swift | 91 +++-- .../Profile/ProfileViewCoordinator.swift | 56 --- .../Sources/Settings/SettingsView.swift | 8 +- .../TodayTab/Sources/Today/TodayView.swift | 88 +++-- .../Sources/Today/TodayViewCoordinator.swift | 32 -- 20 files changed, 304 insertions(+), 916 deletions(-) delete mode 100644 Application/Presentation/Entry/Sources/WindowGroup/TodoWindowCoordinator.swift delete mode 100644 Application/Presentation/HomeTab/Sources/Home/Home/HomeViewCoordinator.swift delete mode 100644 Application/Presentation/NotificationTab/Sources/PushNotificationListViewCoordinator.swift delete mode 100644 Application/Presentation/ProfileTab/Sources/Profile/ProfileRegularDetailView.swift delete mode 100644 Application/Presentation/ProfileTab/Sources/Profile/ProfileViewCoordinator.swift delete mode 100644 Application/Presentation/TodayTab/Sources/Today/TodayViewCoordinator.swift diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index 09f2ebb7..ac955384 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,145 +24,47 @@ 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) + tabView + .onAppear { store.send(.view(.onAppear)) } + .onChange(of: selectedTab, initial: true) { _, tab in + store.send(.view(.selectedTabChanged(tab))) } - } - .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() + .prominentAlert(store, state: \.alert, action: \.alert) + .toastHost() } private var tabView: some View { TabView(selection: $selectedTab) { - homeView - .tabItem { - tabLabel(.home) - } - .tag(MainTab.home) + HomeView( + isSelected: selectedTab == .home, + windowEvent: windowEvent + ) + .tabItem { tabLabel(.home) } + .tag(MainTab.home) - todayView - .tabItem { - tabLabel(.today) - } - .tag(MainTab.today) + TodayView( + isSelected: selectedTab == .today, + windowEvent: windowEvent + ) + .tabItem { tabLabel(.today) } + .tag(MainTab.today) - notificationView - .tabItem { - tabLabel(.notification) - } + PushNotificationListView(isSelected: selectedTab == .notification) + .tabItem { tabLabel(.notification) } .badge(store.unreadPushCount) .tag(MainTab.notification) - profileView - .tabItem { - tabLabel(.profile) - } + ProfileView(isSelected: selectedTab == .profile) + .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 - } - .environment(homeViewCoordinator.router) - case .today: - NavigationSplitView { - mainSidebar - } content: { - todayView - .navigationSplitViewColumnWidth(min: 350, ideal: 450, max: nil) - } detail: { - todayRegularDetailView - } - case .notification: - NavigationSplitView { - mainSidebar - } content: { - PushNotificationListView( - coordinator: pushNotificationListViewCoordinator, - isCompactLayout: isCompactLayout - ) - .navigationSplitViewColumnWidth(min: 350, ideal: 450, max: nil) - } detail: { - notificationRegularDetailView - } - case .profile: - NavigationSplitView { - mainSidebar - } content: { - profileView - .navigationSplitViewColumnWidth(min: 350, ideal: 450, max: nil) - } detail: { - profileRegularDetailView - } - } - } - - private var mainSidebar: some View { - List(selection: sidebarSelection) { - sidebarRow(.home) - sidebarRow(.today) - sidebarRow(.notification) - sidebarRow(.profile) - } - .listStyle(.sidebar) - } - - @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 tabLabel(_ tab: MainTab) -> some View { Label { Text(tab.title) @@ -176,201 +72,8 @@ struct MainView: View { Image(systemName: 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) - } - } - - @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 { 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/Home/HomeFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift index 721d3843..b03a6a76 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift @@ -15,6 +15,7 @@ extension HomeFeature { private enum CancelID: Hashable { case delayedTodoEditor case networkConnectivity + case todoMutation } func observeNetworkConnectivityEffect() -> Effect { @@ -25,6 +26,15 @@ extension HomeFeature { .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) } + func observeTodoMutationEffect() -> Effect { + .publisher { [todoMutationEventBus] in + todoMutationEventBus.observe() + .receive(on: DispatchQueue.main) + .map { _ in .view(.refreshRecentTodos) } + } + .cancellable(id: CancelID.todoMutation, cancelInFlight: true) + } + func fetchTodoCategoryPreferencesEffect() -> Effect { .run { [fetchPreferencesUseCase] send in await send(.loading(.begin(target: LoadingTarget.preferences.target, mode: .immediate))) diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift index 0c268a0e..1c0c7634 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift @@ -144,6 +144,7 @@ struct HomeFeature { @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase @Dependency(\.homeFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.homeNetworkConnectivityUseCase) var networkConnectivityUseCase + @Dependency(\.homeTodoMutationEventBus) var todoMutationEventBus @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase @Dependency(\.continuousClock) var clock @@ -210,7 +211,10 @@ private extension HomeFeature { ) -> Effect { switch action { case .startObserving: - return observeNetworkConnectivityEffect() + return .merge( + observeNetworkConnectivityEffect(), + observeTodoMutationEffect() + ) case .fetchData: return .merge( fetchTodoCategoryPreferencesEffect(), diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift index 979537a3..d1d39197 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,27 +14,53 @@ 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 + NavigationStack(path: $path) { + List { + todoSection + recentTodoSection + } + .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) @@ -175,51 +202,50 @@ public struct HomeView: View { TodoEditorView(store: todoEditorStore) } case .search: - SearchView(store: coordinator.makeSearchStore()) + SearchView(store: searchStore) + } + } + + @ViewBuilder + 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) } } @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) + NavigationLink(value: HomeRoute.category(item)) { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) } } @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) - } + NavigationLink(value: HomeRoute.todo(TodoIdItem(id: item.id))) { + RecentTodoRow(todo: item) } .todoDetailPreview(todoId: item.id) } 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/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..484ee98d 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 { @@ -50,11 +52,13 @@ public struct PushNotificationListView: View { .sheet(item: sheetStore) { 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/Sources/Todo/Detail/TodoDetailView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift index 4179840f..55a6dc8a 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine import ComposableArchitecture import Core import Domain @@ -14,9 +15,14 @@ public struct TodoDetailView: View { @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,6 +41,11 @@ 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 @@ -48,6 +59,10 @@ public struct TodoDetailView: View { .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/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..6bd275e9 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine import ComposableArchitecture import Core import Domain @@ -18,13 +19,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,6 +62,11 @@ 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) @@ -90,6 +99,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/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..72949766 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -12,33 +12,38 @@ import Domain import PresentationShared public struct ProfileView: View { - @Bindable var store: StoreOf + @State private var settingsStore: StoreOf + @State private var store: StoreOf @FocusState private var focused: Bool - let coordinator: ProfileViewCoordinator - let isCompactLayout: Bool + @State private var path = [ProfileRoute]() + private let isSelected: Bool - public init( - coordinator: ProfileViewCoordinator, - isCompactLayout: Bool - ) { - self.store = coordinator.store - self.coordinator = coordinator - self.isCompactLayout = isCompactLayout + public init(isSelected: Bool) { + 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 } public var body: some View { - Group { - if isCompactLayout { - NavigationStack(path: navigationPath) { - profileContentView - .navigationDestination(for: ProfileRoute.self) { route in - ProfileDestinationView(route: route, coordinator: coordinator) - } - } - } else { - profileContentView + NavigationStack(path: $path) { + profileContentView + .navigationDestination(for: ProfileRoute.self, destination: destinationView) + } + .onChange(of: isSelected, initial: true) { _, isSelected in + if isSelected { + store.send(.fetchData) } } + .onAppear { + store.send(.startObserving) + settingsStore.send(.startObserving) + } .onChange(of: focused) { _, newValue in store.send(.updateStatusTextFieldFocus(newValue), animation: .default) } @@ -51,6 +56,32 @@ public struct ProfileView: View { } } + @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 .theme: + ThemeView(theme: $settingsStore.theme) + case .pushNotification: + PushNotificationSettingsView(store: Store( + initialState: PushNotificationSettingsFeature.State() + ) { + PushNotificationSettingsFeature() + }) + case .account: + AccountView(store: Store(initialState: AccountFeature.State()) { + AccountFeature() + }) + } + } + private var profileContentView: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { @@ -231,11 +262,7 @@ public struct ProfileView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { - if isCompactLayout { - coordinator.router.push(.settings) - } else { - coordinator.router.replace(with: .settings) - } + path.append(.settings) } label: { Image(systemName: "gearshape") } @@ -375,21 +402,9 @@ 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 } - - if isCompactLayout { - coordinator.router.push(.activity(activity.todoId)) - } else { - coordinator.router.replace(with: .activity(activity.todoId)) - } + path.append(.activity(activity.todoId)) } } 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/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/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) - } -} From 9514284e6fc44fa1329a9d7715ac0a5c312db059 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 10 Sep 2026 01:19:46 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=ED=83=AD=20=ED=83=90=EC=83=89=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entry/Sources/Main/MainTabBar.swift | 56 +++++++ .../Entry/Sources/Main/MainView.swift | 85 +++------- .../Entry/Sources/Routing/MainTab.swift | 30 +++- .../Home/Category/CategoryManageView.swift | 6 +- .../HomeTab/Sources/Home/Home/HomeView.swift | 12 +- .../Sources/PushNotificationListView.swift | 2 +- .../Common/Component/ExposableTabBar.swift | 149 ++++++++++++++++++ .../Common/TodoMarkdownContentView.swift | 24 +-- .../Sources/Extension/View+Alert.swift | 40 +++-- .../Sources/Todo/Detail/TodoDetailView.swift | 7 +- .../Sources/Todo/Editor/TodoEditorView.swift | 9 +- .../Sources/Todo/List/TodoListView.swift | 2 + .../Sources/Profile/ProfileView.swift | 6 +- .../PushNotificationSettingsView.swift | 6 +- 14 files changed, 332 insertions(+), 102 deletions(-) create mode 100644 Application/Presentation/Entry/Sources/Main/MainTabBar.swift create mode 100644 Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift diff --git a/Application/Presentation/Entry/Sources/Main/MainTabBar.swift b/Application/Presentation/Entry/Sources/Main/MainTabBar.swift new file mode 100644 index 00000000..08ae4827 --- /dev/null +++ b/Application/Presentation/Entry/Sources/Main/MainTabBar.swift @@ -0,0 +1,56 @@ +// +// MainTabBar.swift +// Entry +// +// Created by opfic on 9/9/26. +// + +import SwiftUI +import PresentationShared + +struct MainTabBar: View { + @Binding var selectedTab: MainTab + let unreadPushCount: Int + + var body: some View { + ExposableTabBar( + selection: $selectedTab, + items: MainTab.allCases + ) { tab, isSelected in + VStack(spacing: 3) { + tabIcon(tab) + Text(tab.title) + .font(.caption2) + } + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + } + .padding(.horizontal, 8) + .padding(.top, 12) + .padding(.bottom, 2) + .background { + Rectangle() + .fill(Color(asset: .surface)) + .ignoresSafeArea(edges: .bottom) + } + .overlay(alignment: .top) { + Divider() + } + } + + private func tabIcon(_ tab: MainTab) -> some View { + Image(systemName: tab.symbolName) + .font(.system(size: 20)) + .frame(height: 22) + .overlay(alignment: .topTrailing) { + if tab == .notification, 0 < unreadPushCount { + Text(unreadPushCount < 100 ? "\(unreadPushCount)" : "99+") + .font(.caption2.bold()) + .foregroundStyle(Color.white) + .padding(.horizontal, 5) + .frame(minWidth: 18, minHeight: 18) + .background(Color.red, in: Capsule()) + .offset(x: 14, y: -7) + } + } + } +} diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index ac955384..387d92f6 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -29,75 +29,42 @@ struct MainView: View { } var body: some View { - tabView - .onAppear { store.send(.view(.onAppear)) } - .onChange(of: selectedTab, initial: true) { _, tab in - store.send(.view(.selectedTabChanged(tab))) - } - .prominentAlert(store, state: \.alert, action: \.alert) - .toastHost() + ExposableTabContent( + selection: $selectedTab, + items: MainTab.allCases, + content: tabContent + ) + .toastHost() + .exposableTabBar(isPresented: true) { + MainTabBar( + selectedTab: $selectedTab, + unreadPushCount: store.unreadPushCount + ) + } + .onAppear { store.send(.view(.onAppear)) } + .onChange(of: selectedTab, initial: true) { _, tab in + store.send(.view(.selectedTabChanged(tab))) + } + .prominentAlert(store, state: \.alert, action: \.alert) } - private var tabView: some View { - TabView(selection: $selectedTab) { + @ViewBuilder + private func tabContent(_ tab: MainTab, isSelected: Bool) -> some View { + switch tab { + case .home: HomeView( - isSelected: selectedTab == .home, + isSelected: isSelected, windowEvent: windowEvent ) - .tabItem { tabLabel(.home) } - .tag(MainTab.home) - + case .today: TodayView( - isSelected: selectedTab == .today, + isSelected: isSelected, windowEvent: windowEvent ) - .tabItem { tabLabel(.today) } - .tag(MainTab.today) - - PushNotificationListView(isSelected: selectedTab == .notification) - .tabItem { tabLabel(.notification) } - .badge(store.unreadPushCount) - .tag(MainTab.notification) - - ProfileView(isSelected: selectedTab == .profile) - .tabItem { tabLabel(.profile) } - .tag(MainTab.profile) - } - } - - private func tabLabel(_ tab: MainTab) -> some View { - Label { - Text(tab.title) - } icon: { - Image(systemName: tab.symbolName) - } - } -} - -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 { - case .home: - "house.fill" - case .today: - "sun.max.fill" case .notification: - "bell.fill" + PushNotificationListView(isSelected: isSelected) case .profile: - "person.crop.circle.fill" + ProfileView(isSelected: isSelected) } } } diff --git a/Application/Presentation/Entry/Sources/Routing/MainTab.swift b/Application/Presentation/Entry/Sources/Routing/MainTab.swift index ed041251..6d183eda 100644 --- a/Application/Presentation/Entry/Sources/Routing/MainTab.swift +++ b/Application/Presentation/Entry/Sources/Routing/MainTab.swift @@ -5,9 +5,37 @@ // Created by opfic on 4/30/26. // -public enum MainTab: Hashable { +import Foundation + +public enum MainTab: Hashable, CaseIterable { case home case today case notification case profile + + var title: String { + switch self { + case .home: + String(localized: "nav_home") + case .today: + String(localized: "nav_today") + case .notification: + String(localized: "nav_notifications") + case .profile: + String(localized: "nav_profile") + } + } + + 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/HomeTab/Sources/Home/Category/CategoryManageView.swift b/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift index 16bb96c2..a9e721a9 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(\.isExposableTabContentActive) 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/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift index d1d39197..02e8f94e 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift @@ -62,8 +62,16 @@ public struct HomeView: View { store.send(.view(.todoEditorCreated)) } .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 { diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift index 484ee98d..c1ac687d 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift @@ -49,7 +49,7 @@ 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) } .onChange(of: isSelected, initial: true) { _, isSelected in diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift new file mode 100644 index 00000000..3888f1af --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift @@ -0,0 +1,149 @@ +// +// ExposableTabBar.swift +// PresentationShared +// +// Created by opfic on 9/9/26. +// + +import SwiftUI + +public struct ExposableTabContent: View { + @Binding private var selection: Item + @State private var visitedItems: Set + private let items: [Item] + private let content: (Item, Bool) -> Content + + public init( + selection: Binding, + items: [Item], + @ViewBuilder content: @escaping (Item, Bool) -> Content + ) { + self._selection = selection + self._visitedItems = State(initialValue: [selection.wrappedValue]) + self.items = items + self.content = content + } + + public var body: some View { + ZStack { + ForEach(items, id: \.self) { item in + if visitedItems.contains(item) || selection == item { + let isSelected = selection == item + content(item, isSelected) + .environment(\.isExposableTabContentActive, isSelected) + .opacity(isSelected ? 1 : 0) + .allowsHitTesting(isSelected) + .zIndex(isSelected ? 1 : 0) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: selection) { _, item in + visitedItems.insert(item) + } + } +} + +public struct ExposableTabBar: View { + @Binding private var selection: Item + private let items: [Item] + private let label: (Item, Bool) -> Label + + public init( + selection: Binding, + items: [Item], + @ViewBuilder label: @escaping (Item, Bool) -> Label + ) { + self._selection = selection + self.items = items + self.label = label + } + + public var body: some View { + HStack(spacing: 0) { + ForEach(items, id: \.self) { item in + Button { + selection = item + } label: { + label(item, selection == item) + .frame(maxWidth: .infinity) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + } + } +} + +public extension View { + func exposableTabBar( + isPresented: Bool, + @ViewBuilder content: @escaping () -> TabBar + ) -> some View { + modifier( + ExposableTabBarModifier( + isPresented: isPresented, + tabBar: content + ) + ) + } + +} + +public extension EnvironmentValues { + var isExposableTabContentActive: Bool { + get { self[ExposableTabContentActiveKey.self] } + set { self[ExposableTabContentActiveKey.self] = newValue } + } + + private struct ExposableTabContentActiveKey: 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 + } + } + ) + } +} + +private struct ExposableTabBarModifier: ViewModifier { + let isPresented: Bool + @ViewBuilder let tabBar: () -> TabBar + + func body(content: Content) -> some View { + VStack(spacing: 0) { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + if isPresented { + tabBar() + } + } + } +} diff --git a/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift b/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift index 56d57cea..5b0807e0 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,10 @@ struct TodoMarkdownContentView: View { MarkdownRendererView( markdown: content, references: rendererReferences, - obscuredBottomInset: tabBarHeight, + obscuredBottomInset: .zero, 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 +53,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/Extension/View+Alert.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift index f169c85e..a02f2b45 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(\.isExposableTabContentActive) 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 55a6dc8a..45145cbc 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift @@ -12,6 +12,7 @@ import Core import Domain public struct TodoDetailView: View { + @Environment(\.isExposableTabContentActive) private var isTabContentActive @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @State var store: StoreOf @@ -48,11 +49,15 @@ public struct TodoDetailView: View { } .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) } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 4abf64bb..00c808be 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(\.isExposableTabContentActive) 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(\.isExposableTabContentActive) 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/TodoListView.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift index 6bd275e9..dfebe9fa 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -12,6 +12,7 @@ import Core import Domain public struct TodoListView: View { + @Environment(\.isExposableTabContentActive) private var isTabContentActive @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @@ -70,6 +71,7 @@ public struct TodoListView: View { .navigationTitle(TodoCategoryItem(from: store.category).localizedName) .fullScreenCover( item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + .activePresentation(when: isTabContentActive) ) { coverStore in fullScreenCoverContent(coverStore) } diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index 72949766..3c48bbf7 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -38,6 +38,8 @@ public struct ProfileView: View { .onChange(of: isSelected, initial: true) { _, isSelected in if isSelected { store.send(.fetchData) + } else { + focused = false } } .onAppear { @@ -48,7 +50,9 @@ public struct ProfileView: View { store.send(.updateStatusTextFieldFocus(newValue), animation: .default) } .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(isPresented: $store.showQuarterPicker) { quarterPickerSheet } + .sheet( + isPresented: $store.showQuarterPicker.activePresentation(when: isSelected) + ) { quarterPickerSheet } .overlay { if store.isLoading { LoadingView() diff --git a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift index 6b213450..211dac84 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(\.isExposableTabContentActive) 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 From 6c1e7e7ddbd95fa237be2727df3126bf6949f6e9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 10 Sep 2026 01:23:00 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20Markdown=20=EA=B0=80=EB=A6=BC?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/TodoMarkdownContentView.swift | 1 - .../Sources/MarkdownRendererView.swift | 4 ---- .../MarkdownRenderer/Sources/MarkdownWebView.swift | 10 ---------- 3 files changed, 15 deletions(-) diff --git a/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift b/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift index 5b0807e0..a14b6822 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift @@ -18,7 +18,6 @@ struct TodoMarkdownContentView: View { MarkdownRendererView( markdown: content, references: rendererReferences, - obscuredBottomInset: .zero, onOpenReferenceID: onOpenTodoID ) .frame(maxWidth: .infinity, maxHeight: .infinity) 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, From 79d436c9d823409fb8b05ffde7b0d2da1aabe22b Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 10 Sep 2026 10:51:41 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EC=A0=81=EC=9D=91=ED=98=95=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entry/Sources/Main/MainFeature.swift | 4 ++ .../Entry/Sources/Main/MainTabBar.swift | 56 +++++++++++++++++ .../Entry/Sources/Main/MainView.swift | 23 ++++++- .../Entry/Tests/Main/MainFeatureTests.swift | 12 ++++ .../Common/Component/ExposableTabBar.swift | 60 +++++++++++++++++++ 5 files changed, 154 insertions(+), 1 deletion(-) diff --git a/Application/Presentation/Entry/Sources/Main/MainFeature.swift b/Application/Presentation/Entry/Sources/Main/MainFeature.swift index 18466735..d4e98cfd 100644 --- a/Application/Presentation/Entry/Sources/Main/MainFeature.swift +++ b/Application/Presentation/Entry/Sources/Main/MainFeature.swift @@ -19,6 +19,7 @@ struct MainFeature { @Presents var alert: AlertState? var unreadPushCount = 0 var isObservingUnreadPushCount = false + var isSidebarPresented = true } enum Action: Equatable { @@ -29,6 +30,7 @@ struct MainFeature { enum ViewAction: Equatable { case onAppear case selectedTabChanged(MainTab) + case setSidebarPresented(Bool) } enum StoreAction: Equatable { @@ -57,6 +59,8 @@ struct MainFeature { case .view(.selectedTabChanged(let tab)): guard let screenName = tab.analyticsScreenName else { break } return trackScreenViewEffect(screenName) + case .view(.setSidebarPresented(let isPresented)): + state.isSidebarPresented = isPresented case .store(.setUnreadPushCount(let count)): state.unreadPushCount = count return updateBadgeCountEffect(count) diff --git a/Application/Presentation/Entry/Sources/Main/MainTabBar.swift b/Application/Presentation/Entry/Sources/Main/MainTabBar.swift index 08ae4827..9e9d0dc1 100644 --- a/Application/Presentation/Entry/Sources/Main/MainTabBar.swift +++ b/Application/Presentation/Entry/Sources/Main/MainTabBar.swift @@ -54,3 +54,59 @@ struct MainTabBar: View { } } } + +struct MainSideBar: View { + @Environment(\.safeAreaInsets) private var safeAreaInsets + @ScaledMetric(relativeTo: .caption) private var badgeSize = CGFloat(20) + @Binding var selectedTab: MainTab + let unreadPushCount: Int + + var body: some View { + VStack { + LazyVStack(spacing: 4) { + ForEach([MainTab.home, .today, .notification], id: \.self) { tab in + tabButton(tab) + } + } + Spacer() + tabButton(.profile) + } + .padding(.top, safeAreaInsets.top + 56) + .padding(.horizontal, 12) + .frame(width: 280) + .frame(maxHeight: .infinity) + .background { + Color(asset: .surface).ignoresSafeArea() + } + } + + private func tabButton(_ tab: MainTab) -> some View { + Button { + selectedTab = tab + } label: { + HStack(spacing: 12) { + Image(systemName: tab.symbolName) + .font(.system(size: 18)) + .frame(width: 24) + Text(tab.title) + Spacer() + if tab == .notification, 0 < unreadPushCount { + Text(unreadPushCount < 100 ? "\(unreadPushCount)" : "99+") + .foregroundStyle(Color.white) + .font(.caption) + .frame(minWidth: badgeSize, minHeight: badgeSize) + .background(Color.red, in: Capsule()) + } + } + .foregroundStyle(selectedTab == tab ? Color.accentColor : Color.primary) + .padding(.horizontal, 12) + .frame(height: 44) + .background( + selectedTab == tab ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 10) + ) + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index 387d92f6..0a92b5d3 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -13,6 +13,7 @@ import PresentationShared import TodayTab struct MainView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Binding var selectedTab: MainTab @State private var store: StoreOf private let windowEvent: TodoEditorWindowEvent @@ -35,12 +36,21 @@ struct MainView: View { content: tabContent ) .toastHost() - .exposableTabBar(isPresented: true) { + .exposableTabBar(isPresented: !usesSidebar) { MainTabBar( selectedTab: $selectedTab, unreadPushCount: store.unreadPushCount ) } + .exposableSideBar( + isPresented: sidebarPresentation, + showsToggle: usesSidebar + ) { + MainSideBar( + selectedTab: $selectedTab, + unreadPushCount: store.unreadPushCount + ) + } .onAppear { store.send(.view(.onAppear)) } .onChange(of: selectedTab, initial: true) { _, tab in store.send(.view(.selectedTabChanged(tab))) @@ -67,4 +77,15 @@ struct MainView: View { ProfileView(isSelected: isSelected) } } + + private var usesSidebar: Bool { + horizontalSizeClass == .regular + } + + private var sidebarPresentation: Binding { + Binding( + get: { usesSidebar && store.isSidebarPresented }, + set: { store.send(.view(.setSidebarPresented($0))) } + ) + } } diff --git a/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift b/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift index 5e5a7392..ac72d402 100644 --- a/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift @@ -14,6 +14,18 @@ import Testing @MainActor struct MainFeatureTests { + @Test("MainFeature는 사이드바 표시 상태를 갱신한다") + func MainFeature는_사이드바_표시_상태를_갱신한다() async { + let store = makeStore() + + await store.send(.view(.setSidebarPresented(false))) { + $0.isSidebarPresented = false + } + await store.send(.view(.setSidebarPresented(true))) { + $0.isSidebarPresented = true + } + } + @Test("MainFeature는 기존 Main 상태관리처럼 최초 onAppear에서만 unread count 관찰을 시작한다") func MainFeature는_기존_Main_상태관리처럼_최초_onAppear에서만_unread_count_관찰을_시작한다() async { let reference = MainStateManagementReference() diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift index 3888f1af..95ce3c56 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift @@ -147,3 +147,63 @@ private struct ExposableTabBarModifier: ViewModifier { } } } + +public extension View { + func exposableSideBar( + isPresented: Binding, + showsToggle: Bool, + @ViewBuilder content: @escaping () -> SideBar + ) -> some View { + modifier( + ExposableSideBarModifier( + isPresented: isPresented, + showsToggle: showsToggle, + sideBar: content + ) + ) + } +} + +private struct ExposableSideBarModifier: ViewModifier { + @Binding var isPresented: Bool + let showsToggle: Bool + @ViewBuilder let sideBar: () -> SideBar + + func body(content: Content) -> some View { + HStack(spacing: 0) { + sideBarArea + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .overlay(alignment: .topLeading) { + if showsToggle { + Button { + isPresented.toggle() + } label: { + Image(systemName: "sidebar.left") + .font(.headline) + .adaptiveButtonStyle() + } + .buttonStyle(.plain) + .padding(.top, 8) + .padding(.leading, 8) + } + } + .animation(.snappy, value: isPresented) + } + + @ViewBuilder + private var sideBarArea: some View { + if isPresented { + HStack(spacing: 0) { + sideBar() + Divider() + } + .transition(.move(edge: .leading).combined(with: .opacity)) + } else { + Color.clear + .frame(width: 0) + .allowsHitTesting(false) + } + } +} From fa6a14575a50cef9d6c46db504865d5e1d355f9a Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 10 Sep 2026 15:13:53 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20iOS=2018=20=EC=A0=81=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=ED=83=AD=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entry/Sources/Main/MainFeature.swift | 4 - .../Entry/Sources/Main/MainTabBar.swift | 112 ---------- .../Entry/Sources/Main/MainView.swift | 69 +++--- .../Entry/Tests/Main/MainFeatureTests.swift | 12 - .../Common/Component/ActivePresentation.swift | 51 +++++ .../Common/Component/ExposableTabBar.swift | 209 ------------------ Application/Shared/Version.xcconfig | 2 +- Libraries/MarkdownRenderer/Project.swift | 2 +- Libraries/ThirdParty/Project.swift | 2 +- 9 files changed, 90 insertions(+), 373 deletions(-) delete mode 100644 Application/Presentation/Entry/Sources/Main/MainTabBar.swift create mode 100644 Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift delete mode 100644 Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift diff --git a/Application/Presentation/Entry/Sources/Main/MainFeature.swift b/Application/Presentation/Entry/Sources/Main/MainFeature.swift index d4e98cfd..18466735 100644 --- a/Application/Presentation/Entry/Sources/Main/MainFeature.swift +++ b/Application/Presentation/Entry/Sources/Main/MainFeature.swift @@ -19,7 +19,6 @@ struct MainFeature { @Presents var alert: AlertState? var unreadPushCount = 0 var isObservingUnreadPushCount = false - var isSidebarPresented = true } enum Action: Equatable { @@ -30,7 +29,6 @@ struct MainFeature { enum ViewAction: Equatable { case onAppear case selectedTabChanged(MainTab) - case setSidebarPresented(Bool) } enum StoreAction: Equatable { @@ -59,8 +57,6 @@ struct MainFeature { case .view(.selectedTabChanged(let tab)): guard let screenName = tab.analyticsScreenName else { break } return trackScreenViewEffect(screenName) - case .view(.setSidebarPresented(let isPresented)): - state.isSidebarPresented = isPresented case .store(.setUnreadPushCount(let count)): state.unreadPushCount = count return updateBadgeCountEffect(count) diff --git a/Application/Presentation/Entry/Sources/Main/MainTabBar.swift b/Application/Presentation/Entry/Sources/Main/MainTabBar.swift deleted file mode 100644 index 9e9d0dc1..00000000 --- a/Application/Presentation/Entry/Sources/Main/MainTabBar.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// MainTabBar.swift -// Entry -// -// Created by opfic on 9/9/26. -// - -import SwiftUI -import PresentationShared - -struct MainTabBar: View { - @Binding var selectedTab: MainTab - let unreadPushCount: Int - - var body: some View { - ExposableTabBar( - selection: $selectedTab, - items: MainTab.allCases - ) { tab, isSelected in - VStack(spacing: 3) { - tabIcon(tab) - Text(tab.title) - .font(.caption2) - } - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - } - .padding(.horizontal, 8) - .padding(.top, 12) - .padding(.bottom, 2) - .background { - Rectangle() - .fill(Color(asset: .surface)) - .ignoresSafeArea(edges: .bottom) - } - .overlay(alignment: .top) { - Divider() - } - } - - private func tabIcon(_ tab: MainTab) -> some View { - Image(systemName: tab.symbolName) - .font(.system(size: 20)) - .frame(height: 22) - .overlay(alignment: .topTrailing) { - if tab == .notification, 0 < unreadPushCount { - Text(unreadPushCount < 100 ? "\(unreadPushCount)" : "99+") - .font(.caption2.bold()) - .foregroundStyle(Color.white) - .padding(.horizontal, 5) - .frame(minWidth: 18, minHeight: 18) - .background(Color.red, in: Capsule()) - .offset(x: 14, y: -7) - } - } - } -} - -struct MainSideBar: View { - @Environment(\.safeAreaInsets) private var safeAreaInsets - @ScaledMetric(relativeTo: .caption) private var badgeSize = CGFloat(20) - @Binding var selectedTab: MainTab - let unreadPushCount: Int - - var body: some View { - VStack { - LazyVStack(spacing: 4) { - ForEach([MainTab.home, .today, .notification], id: \.self) { tab in - tabButton(tab) - } - } - Spacer() - tabButton(.profile) - } - .padding(.top, safeAreaInsets.top + 56) - .padding(.horizontal, 12) - .frame(width: 280) - .frame(maxHeight: .infinity) - .background { - Color(asset: .surface).ignoresSafeArea() - } - } - - private func tabButton(_ tab: MainTab) -> some View { - Button { - selectedTab = tab - } label: { - HStack(spacing: 12) { - Image(systemName: tab.symbolName) - .font(.system(size: 18)) - .frame(width: 24) - Text(tab.title) - Spacer() - if tab == .notification, 0 < unreadPushCount { - Text(unreadPushCount < 100 ? "\(unreadPushCount)" : "99+") - .foregroundStyle(Color.white) - .font(.caption) - .frame(minWidth: badgeSize, minHeight: badgeSize) - .background(Color.red, in: Capsule()) - } - } - .foregroundStyle(selectedTab == tab ? Color.accentColor : Color.primary) - .padding(.horizontal, 12) - .frame(height: 44) - .background( - selectedTab == tab ? Color.accentColor.opacity(0.12) : Color.clear, - in: RoundedRectangle(cornerRadius: 10) - ) - .contentShape(.rect) - } - .buttonStyle(.plain) - } -} diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index 0a92b5d3..f608fa55 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -13,7 +13,6 @@ import PresentationShared import TodayTab struct MainView: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Binding var selectedTab: MainTab @State private var store: StoreOf private let windowEvent: TodoEditorWindowEvent @@ -30,27 +29,31 @@ struct MainView: View { } var body: some View { - ExposableTabContent( - selection: $selectedTab, - items: MainTab.allCases, - content: tabContent - ) - .toastHost() - .exposableTabBar(isPresented: !usesSidebar) { - MainTabBar( - selectedTab: $selectedTab, - unreadPushCount: store.unreadPushCount - ) - } - .exposableSideBar( - isPresented: sidebarPresentation, - showsToggle: usesSidebar - ) { - MainSideBar( - selectedTab: $selectedTab, - unreadPushCount: store.unreadPushCount - ) + TabView(selection: $selectedTab) { + Tab(value: MainTab.home) { + tabContent(.home) + } label: { + tabLabel(.home) + } + Tab(value: MainTab.today) { + tabContent(.today) + } label: { + tabLabel(.today) + } + Tab(value: MainTab.notification) { + tabContent(.notification) + } label: { + tabLabel(.notification) + } + .badge(store.unreadPushCount) + Tab(value: MainTab.profile) { + tabContent(.profile) + } label: { + tabLabel(.profile) + } } + .tabViewStyle(.sidebarAdaptable) + .toastHost() .onAppear { store.send(.view(.onAppear)) } .onChange(of: selectedTab, initial: true) { _, tab in store.send(.view(.selectedTabChanged(tab))) @@ -59,7 +62,18 @@ struct MainView: View { } @ViewBuilder - private func tabContent(_ tab: MainTab, isSelected: Bool) -> some View { + private func tabContent(_ tab: MainTab) -> some View { + let isSelected = selectedTab == tab + tabView(tab, isSelected: isSelected) + .environment(\.isExposableTabContentActive, isSelected) + } + + private func tabLabel(_ tab: MainTab) -> some View { + Label(tab.title, systemImage: tab.symbolName) + } + + @ViewBuilder + private func tabView(_ tab: MainTab, isSelected: Bool) -> some View { switch tab { case .home: HomeView( @@ -77,15 +91,4 @@ struct MainView: View { ProfileView(isSelected: isSelected) } } - - private var usesSidebar: Bool { - horizontalSizeClass == .regular - } - - private var sidebarPresentation: Binding { - Binding( - get: { usesSidebar && store.isSidebarPresented }, - set: { store.send(.view(.setSidebarPresented($0))) } - ) - } } diff --git a/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift b/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift index ac72d402..5e5a7392 100644 --- a/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift @@ -14,18 +14,6 @@ import Testing @MainActor struct MainFeatureTests { - @Test("MainFeature는 사이드바 표시 상태를 갱신한다") - func MainFeature는_사이드바_표시_상태를_갱신한다() async { - let store = makeStore() - - await store.send(.view(.setSidebarPresented(false))) { - $0.isSidebarPresented = false - } - await store.send(.view(.setSidebarPresented(true))) { - $0.isSidebarPresented = true - } - } - @Test("MainFeature는 기존 Main 상태관리처럼 최초 onAppear에서만 unread count 관찰을 시작한다") func MainFeature는_기존_Main_상태관리처럼_최초_onAppear에서만_unread_count_관찰을_시작한다() async { let reference = MainStateManagementReference() 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..acd3aec7 --- /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 isExposableTabContentActive: Bool { + get { self[ExposableTabContentActiveKey.self] } + set { self[ExposableTabContentActiveKey.self] = newValue } + } + + private struct ExposableTabContentActiveKey: 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/Component/ExposableTabBar.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift deleted file mode 100644 index 95ce3c56..00000000 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/ExposableTabBar.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// ExposableTabBar.swift -// PresentationShared -// -// Created by opfic on 9/9/26. -// - -import SwiftUI - -public struct ExposableTabContent: View { - @Binding private var selection: Item - @State private var visitedItems: Set - private let items: [Item] - private let content: (Item, Bool) -> Content - - public init( - selection: Binding, - items: [Item], - @ViewBuilder content: @escaping (Item, Bool) -> Content - ) { - self._selection = selection - self._visitedItems = State(initialValue: [selection.wrappedValue]) - self.items = items - self.content = content - } - - public var body: some View { - ZStack { - ForEach(items, id: \.self) { item in - if visitedItems.contains(item) || selection == item { - let isSelected = selection == item - content(item, isSelected) - .environment(\.isExposableTabContentActive, isSelected) - .opacity(isSelected ? 1 : 0) - .allowsHitTesting(isSelected) - .zIndex(isSelected ? 1 : 0) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: selection) { _, item in - visitedItems.insert(item) - } - } -} - -public struct ExposableTabBar: View { - @Binding private var selection: Item - private let items: [Item] - private let label: (Item, Bool) -> Label - - public init( - selection: Binding, - items: [Item], - @ViewBuilder label: @escaping (Item, Bool) -> Label - ) { - self._selection = selection - self.items = items - self.label = label - } - - public var body: some View { - HStack(spacing: 0) { - ForEach(items, id: \.self) { item in - Button { - selection = item - } label: { - label(item, selection == item) - .frame(maxWidth: .infinity) - .contentShape(.rect) - } - .buttonStyle(.plain) - } - } - } -} - -public extension View { - func exposableTabBar( - isPresented: Bool, - @ViewBuilder content: @escaping () -> TabBar - ) -> some View { - modifier( - ExposableTabBarModifier( - isPresented: isPresented, - tabBar: content - ) - ) - } - -} - -public extension EnvironmentValues { - var isExposableTabContentActive: Bool { - get { self[ExposableTabContentActiveKey.self] } - set { self[ExposableTabContentActiveKey.self] = newValue } - } - - private struct ExposableTabContentActiveKey: 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 - } - } - ) - } -} - -private struct ExposableTabBarModifier: ViewModifier { - let isPresented: Bool - @ViewBuilder let tabBar: () -> TabBar - - func body(content: Content) -> some View { - VStack(spacing: 0) { - content - .frame(maxWidth: .infinity, maxHeight: .infinity) - if isPresented { - tabBar() - } - } - } -} - -public extension View { - func exposableSideBar( - isPresented: Binding, - showsToggle: Bool, - @ViewBuilder content: @escaping () -> SideBar - ) -> some View { - modifier( - ExposableSideBarModifier( - isPresented: isPresented, - showsToggle: showsToggle, - sideBar: content - ) - ) - } -} - -private struct ExposableSideBarModifier: ViewModifier { - @Binding var isPresented: Bool - let showsToggle: Bool - @ViewBuilder let sideBar: () -> SideBar - - func body(content: Content) -> some View { - HStack(spacing: 0) { - sideBarArea - content - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .overlay(alignment: .topLeading) { - if showsToggle { - Button { - isPresented.toggle() - } label: { - Image(systemName: "sidebar.left") - .font(.headline) - .adaptiveButtonStyle() - } - .buttonStyle(.plain) - .padding(.top, 8) - .padding(.leading, 8) - } - } - .animation(.snappy, value: isPresented) - } - - @ViewBuilder - private var sideBarArea: some View { - if isPresented { - HStack(spacing: 0) { - sideBar() - Divider() - } - .transition(.move(edge: .leading).combined(with: .opacity)) - } else { - Color.clear - .frame(width: 0) - .allowsHitTesting(false) - } - } -} 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/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", ] From b200e6e399c22d9da0a6bc598b503231debe9830 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 10 Sep 2026 15:36:02 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20Exposable=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Entry/Sources/Main/MainView.swift | 2 +- .../Sources/Home/Category/CategoryManageView.swift | 2 +- .../Sources/Common/Component/ActivePresentation.swift | 8 ++++---- .../PresentationShared/Sources/Extension/View+Alert.swift | 2 +- .../Sources/Todo/Detail/TodoDetailView.swift | 2 +- .../Sources/Todo/Editor/TodoEditorView.swift | 4 ++-- .../Sources/Todo/List/TodoListView.swift | 2 +- .../Sources/Settings/PushNotificationSettingsView.swift | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index f608fa55..272a98e7 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -65,7 +65,7 @@ struct MainView: View { private func tabContent(_ tab: MainTab) -> some View { let isSelected = selectedTab == tab tabView(tab, isSelected: isSelected) - .environment(\.isExposableTabContentActive, isSelected) + .environment(\.isTabContentActive, isSelected) } private func tabLabel(_ tab: MainTab) -> some View { diff --git a/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift b/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift index a9e721a9..9ad1f99a 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift @@ -9,7 +9,7 @@ import SwiftUI import PresentationShared struct CategoryManageView: View { - @Environment(\.isExposableTabContentActive) private var isTabContentActive + @Environment(\.isTabContentActive) private var isTabContentActive @Bindable var store: StoreOf var body: some View { diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift index acd3aec7..17ce6e5a 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/ActivePresentation.swift @@ -8,12 +8,12 @@ import SwiftUI public extension EnvironmentValues { - var isExposableTabContentActive: Bool { - get { self[ExposableTabContentActiveKey.self] } - set { self[ExposableTabContentActiveKey.self] = newValue } + var isTabContentActive: Bool { + get { self[TabContentActiveKey.self] } + set { self[TabContentActiveKey.self] = newValue } } - private struct ExposableTabContentActiveKey: EnvironmentKey { + private struct TabContentActiveKey: EnvironmentKey { static let defaultValue = true } } diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift index a02f2b45..8b8978f0 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift @@ -28,7 +28,7 @@ public extension View { private struct ProminentAlertModifier: ViewModifier where State: ObservableState { - @Environment(\.isExposableTabContentActive) private var isTabContentActive + @Environment(\.isTabContentActive) private var isTabContentActive let store: Store let alertState: KeyPath?> diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift index 45145cbc..4934e89d 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Detail/TodoDetailView.swift @@ -12,7 +12,7 @@ import Core import Domain public struct TodoDetailView: View { - @Environment(\.isExposableTabContentActive) private var isTabContentActive + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @State var store: StoreOf diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 00c808be..82256726 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -12,7 +12,7 @@ import Domain public struct TodoEditorView: View { @Environment(\.dismiss) private var dismiss - @Environment(\.isExposableTabContentActive) private var isTabContentActive + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @State var store: StoreOf @FocusState private var field: Field? @@ -449,7 +449,7 @@ private struct TodoEditorInfoSheetView: View { } private struct DueDatePicker: View { - @Environment(\.isExposableTabContentActive) private var isTabContentActive + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.safeAreaInsets) private var safeAreaInsets @State private var isPresented: Bool = false @State private var height: CGFloat = .pi diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift index dfebe9fa..cb5fd48b 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -12,7 +12,7 @@ import Core import Domain public struct TodoListView: View { - @Environment(\.isExposableTabContentActive) private var isTabContentActive + @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac diff --git a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift index 211dac84..60746640 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/PushNotificationSettingsView.swift @@ -9,7 +9,7 @@ import SwiftUI import PresentationShared struct PushNotificationSettingsView: View { - @Environment(\.isExposableTabContentActive) private var isTabContentActive + @Environment(\.isTabContentActive) private var isTabContentActive @State var store: StoreOf var body: some View { From 4cfe33ac3d157cd90d9b1f66093dbdf5d00a3134 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 10 Sep 2026 20:53:21 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Profile/ProfileView.swift | 230 ++++++++++-------- 1 file changed, 122 insertions(+), 108 deletions(-) diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index 3c48bbf7..a8aa5059 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -14,7 +14,6 @@ import PresentationShared public struct ProfileView: View { @State private var settingsStore: StoreOf @State private var store: StoreOf - @FocusState private var focused: Bool @State private var path = [ProfileRoute]() private let isSelected: Bool @@ -32,23 +31,31 @@ public struct ProfileView: View { public var body: some View { NavigationStack(path: $path) { - profileContentView - .navigationDestination(for: ProfileRoute.self, destination: destinationView) + ScrollView { + LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { + Section { + ProfileCard(store: store, isSelected: isSelected) + } header: { + titleBar + } + } + .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) - } else { - focused = false } } .onAppear { store.send(.startObserving) settingsStore.send(.startObserving) } - .onChange(of: focused) { _, newValue in - store.send(.updateStatusTextFieldFocus(newValue), animation: .default) - } .prominentAlert(store, state: \.alert, action: \.alert) .sheet( isPresented: $store.showQuarterPicker.activePresentation(when: isSelected) @@ -60,6 +67,26 @@ public struct ProfileView: View { } } + private var titleBar: some View { + VStack(alignment: .leading) { + HStack { + Text("프로필") + .font(.largeTitle.bold()) + Spacer() + Button { + path.append(.settings) + } label: { + Image(systemName: "gearshape") + .foregroundStyle(Color(asset: .textTertiary)) + } + .adaptiveButtonStyle() + } + Text("꾸준히 쌓아온 개발 기록을 확인하세요") + .foregroundStyle(Color(asset: .textSecondary)) + .font(.caption) + } + } + @ViewBuilder private func destinationView(_ route: ProfileRoute) -> some View { switch route { @@ -86,95 +113,6 @@ 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 { - 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 { - Button { - focused = false - store.send(.willUpdateStatusMessage) - } label: { - Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .opacity(connected ? 1 : 0.7) - } - private var activityHeatmapSection: some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -262,17 +200,6 @@ public struct ProfileView: View { ) } - @ToolbarContentBuilder - private var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - path.append(.settings) - } label: { - Image(systemName: "gearshape") - } - } - } - private var quarterPickerSheet: some View { NavigationStack { VStack(alignment: .leading, spacing: 20) { @@ -412,6 +339,93 @@ public struct ProfileView: View { } } +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) + } + } + + 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) + } + } +} + enum ProfileRoute: Hashable { case settings case activity(String) From 0da90e84f735f171e63d17d8e1e706f83f6e7226 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 00:44:45 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=EC=B5=9C=EA=B7=BC=20Todo=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=9D=84=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 8 +- .../Entry/Sources/Main/MainView.swift | 5 +- .../Entry/Sources/Routing/MainTab.swift | 9 +- .../Home/Home/HomeFeature+Dependencies.swift | 15 -- .../Home/Home/HomeFeature+Effects.swift | 55 ------ .../Sources/Home/Home/HomeFeature.swift | 28 +-- .../HomeTab/Sources/Home/Home/HomeView.swift | 85 --------- .../Home/HomeDependencyPreparation.swift | 17 +- .../Home/HomeFeatureTestAssertions.swift | 44 +---- .../Tests/Home/HomeFeatureTestSpies.swift | 14 +- .../Tests/Home/HomeFeatureTestSupport.swift | 31 --- .../HomeTab/Tests/Home/HomeFeatureTests.swift | 19 +- .../Resources/Localizable.xcstrings | 8 +- .../ProfileDependencyPreparation.swift | 7 + .../Profile/ProfileFeature+Dependencies.swift | 11 ++ .../Profile/ProfileFeature+RecentTodos.swift | 88 +++++++++ .../Profile/ProfileFeature+State.swift | 6 +- .../Sources/Profile/ProfileFeature.swift | 34 +++- .../Sources/Profile/ProfileView.swift | 144 +++++++++++++- .../{Profile => }/ActivityKindItem.swift | 0 .../{Profile => }/HeatmapActivityItem.swift | 0 .../Structure/{Profile => }/HeatmapDay.swift | 0 .../{Profile => }/HeatmapMonth.swift | 0 .../{Profile => }/HeatmapQuarter.swift | 0 .../ProfileAvatarImageData.swift | 0 .../Sources/Structure}/RecentTodoItem.swift | 4 +- .../Tests/Profile/ProfileFeatureTests.swift | 178 +++++++++++++++++- 27 files changed, 494 insertions(+), 316 deletions(-) create mode 100644 Application/Presentation/ProfileTab/Sources/Profile/ProfileFeature+RecentTodos.swift rename Application/Presentation/ProfileTab/Sources/Structure/{Profile => }/ActivityKindItem.swift (100%) rename Application/Presentation/ProfileTab/Sources/Structure/{Profile => }/HeatmapActivityItem.swift (100%) rename Application/Presentation/ProfileTab/Sources/Structure/{Profile => }/HeatmapDay.swift (100%) rename Application/Presentation/ProfileTab/Sources/Structure/{Profile => }/HeatmapMonth.swift (100%) rename Application/Presentation/ProfileTab/Sources/Structure/{Profile => }/HeatmapQuarter.swift (100%) rename Application/Presentation/ProfileTab/Sources/Structure/{Profile => }/ProfileAvatarImageData.swift (100%) rename Application/Presentation/{HomeTab/Sources/Home/Structure/Todo => ProfileTab/Sources/Structure}/RecentTodoItem.swift (92%) diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 61467727..c967ec44 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -84,12 +84,10 @@ private extension AppGraph { &dependencies, updateTodoCategoryPreferencesUseCase: todoGraphSet .todoCategoryUseCaseGraph - .updateTodoCategoryPreferencesUseCase, - todoMutationEventBus: todoGraphSet.todoMutationEventBusGraph.todoMutationEventBus + .updateTodoCategoryPreferencesUseCase ) HomePresentationDependencyPreparation.prepareTodo( &dependencies, - fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, networkConnectivityUseCase: networkConnectivityGraphSet .networkConnectivityUseCaseGraph .observeNetworkConnectivityUseCase @@ -173,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 272a98e7..8fda5635 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -88,7 +88,10 @@ struct MainView: View { case .notification: PushNotificationListView(isSelected: isSelected) case .profile: - ProfileView(isSelected: isSelected) + ProfileView( + isSelected: isSelected, + windowEvent: windowEvent + ) } } } diff --git a/Application/Presentation/Entry/Sources/Routing/MainTab.swift b/Application/Presentation/Entry/Sources/Routing/MainTab.swift index 6d183eda..0db4451f 100644 --- a/Application/Presentation/Entry/Sources/Routing/MainTab.swift +++ b/Application/Presentation/Entry/Sources/Routing/MainTab.swift @@ -6,6 +6,7 @@ // import Foundation +import PresentationShared public enum MainTab: Hashable, CaseIterable { case home @@ -16,13 +17,13 @@ public enum MainTab: Hashable, CaseIterable { var title: String { switch self { case .home: - String(localized: "nav_home") + String(localized: "nav_home", bundle: PresentationResources.bundle) case .today: - String(localized: "nav_today") + String(localized: "nav_today", bundle: PresentationResources.bundle) case .notification: - String(localized: "nav_notifications") + String(localized: "nav_notifications", bundle: PresentationResources.bundle) case .profile: - String(localized: "nav_profile") + String(localized: "nav_profile", bundle: PresentationResources.bundle) } } diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift index bbc3bdc2..f004334b 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift @@ -14,11 +14,6 @@ extension DependencyValues { set { self[HomeUpdatePreferencesUseCaseKey.self] = newValue } } - var homeFetchTodosUseCase: FetchTodosUseCase { - get { self[HomeFetchTodosUseCaseKey.self] } - set { self[HomeFetchTodosUseCaseKey.self] = newValue } - } - var homeNetworkConnectivityUseCase: ObserveNetworkConnectivityUseCase { get { self[HomeNetworkConnectivityUseCaseKey.self] } set { self[HomeNetworkConnectivityUseCaseKey.self] = newValue } @@ -35,16 +30,6 @@ private enum HomeUpdatePreferencesUseCaseKey: DependencyKey { } } -private enum HomeFetchTodosUseCaseKey: DependencyKey { - static var liveValue: FetchTodosUseCase { - preconditionFailure("FetchTodosUseCase must be provided.") - } - - static var testValue: FetchTodosUseCase { - 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 b03a6a76..a7f4a15d 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift @@ -15,7 +15,6 @@ extension HomeFeature { private enum CancelID: Hashable { case delayedTodoEditor case networkConnectivity - case todoMutation } func observeNetworkConnectivityEffect() -> Effect { @@ -26,15 +25,6 @@ extension HomeFeature { .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) } - func observeTodoMutationEffect() -> Effect { - .publisher { [todoMutationEventBus] in - todoMutationEventBus.observe() - .receive(on: DispatchQueue.main) - .map { _ in .view(.refreshRecentTodos) } - } - .cancellable(id: CancelID.todoMutation, cancelInFlight: true) - } - func fetchTodoCategoryPreferencesEffect() -> Effect { .run { [fetchPreferencesUseCase] send in await send(.loading(.begin(target: LoadingTarget.preferences.target, mode: .immediate))) @@ -48,23 +38,6 @@ extension HomeFeature { } } - 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))) - } - await send(.loading(.end(target: LoadingTarget.recentTodos.target, mode: .immediate))) - } - } - func trackTodoCreateEffect() -> Effect { .run { [trackAnalyticsEventUseCase] _ in trackAnalyticsEventUseCase.execute(.todoCreate) @@ -90,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, @@ -143,21 +105,4 @@ extension HomeFeature { } } - 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/HomeTab/Sources/Home/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift index 1c0c7634..5837d661 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift @@ -17,7 +17,6 @@ struct HomeFeature { @Presents var sheet: SheetState? @Presents var fullScreenCover: FullScreenCoverState? var preferences = [TodoCategoryItem]() - var recentTodos = [RecentTodoItem]() var isNetworkConnected = true var selectedTodoCategory: TodoCategory? var loading = LoadingFeature.State() @@ -37,10 +36,6 @@ struct HomeFeature { loading.visibleTargets.contains(LoadingTarget.preferences.target) } - var isRecentTodosLoading: Bool { - loading.visibleTargets.contains(LoadingTarget.recentTodos.target) - } - } enum Action: BindableAction, Equatable { @@ -55,7 +50,6 @@ struct HomeFeature { enum ViewAction: Equatable { case startObserving case fetchData - case refreshRecentTodos case todoEditorCreated case tapManageTodoCategory case tapTodoCategory(TodoCategory) @@ -67,7 +61,6 @@ struct HomeFeature { case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool) case setTodoCategory([TodoCategoryItem]) - case updateRecentTodos([RecentTodoItem]) } } @@ -128,23 +121,18 @@ struct HomeFeature { enum LoadingTarget: Hashable { case preferences - case recentTodos var target: LoadingFeature.Target { switch self { case .preferences: return LoadingFeature.Target("home.preferences") - case .recentTodos: - return LoadingFeature.Target("home.recentTodos") } } } @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase - @Dependency(\.homeFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.homeNetworkConnectivityUseCase) var networkConnectivityUseCase - @Dependency(\.homeTodoMutationEventBus) var todoMutationEventBus @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase @Dependency(\.continuousClock) var clock @@ -211,17 +199,9 @@ private extension HomeFeature { ) -> Effect { switch action { case .startObserving: - return .merge( - observeNetworkConnectivityEffect(), - observeTodoMutationEffect() - ) + return observeNetworkConnectivityEffect() case .fetchData: - return .merge( - fetchTodoCategoryPreferencesEffect(), - fetchRecentTodosEffect() - ) - case .refreshRecentTodos: - return fetchRecentTodosEffect() + return fetchTodoCategoryPreferencesEffect() case .todoEditorCreated: state.fullScreenCover = nil state.selectedTodoCategory = nil @@ -245,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) } @@ -265,9 +244,6 @@ private extension HomeFeature { 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 } return .none diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift index 02e8f94e..2f8469b1 100644 --- a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift @@ -43,7 +43,6 @@ public struct HomeView: View { NavigationStack(path: $path) { List { todoSection - recentTodoSection } .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_home", bundle: PresentationResources.bundle)) @@ -104,33 +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()) - } - } - @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { @@ -250,14 +222,6 @@ public struct HomeView: View { } } - @ViewBuilder - private func recentTodoRow(_ item: RecentTodoItem) -> some View { - NavigationLink(value: HomeRoute.todo(TodoIdItem(id: item.id))) { - RecentTodoRow(todo: item) - } - .todoDetailPreview(todoId: item.id) - } - private func labelImage( text: String, systemName: String, @@ -297,52 +261,3 @@ public enum HomeRoute: Hashable { case category(TodoCategoryItem) case todo(TodoIdItem) } - -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/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift index 4090ec15..b2f571e5 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift @@ -11,19 +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 prepareTodo( _ dependencies: inout DependencyValues, - fetchTodosUseCase: FetchTodosUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCase ) { - dependencies.homeFetchTodosUseCase = fetchTodosUseCase dependencies.homeNetworkConnectivityUseCase = networkConnectivityUseCase } @@ -40,23 +36,12 @@ public enum HomeDependencyPreparation { } 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/Tests/Home/HomeFeatureTestAssertions.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift index be864d27..460511cd 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift @@ -13,22 +13,15 @@ import PresentationShared @MainActor func verifyHomeFetchData( - adapter: HomeStoreTestAdapter, - fetchTodosUseCaseSpy: FetchTodosUseCaseSpy + adapter: HomeStoreTestAdapter ) async throws { await adapter.fetchData() await waitUntil { adapter.preferences.count == 2 - && adapter.recentTodos.count == 2 } #expect(adapter.preferences.map(\.id) == ["feature", "custom"]) - #expect(adapter.recentTodos.map(\.id) == ["todo-1", "todo-2"]) - #expect(fetchTodosUseCaseSpy.queries.count == 1) - #expect(fetchTodosUseCaseSpy.queries.first?.sortTarget == .updatedAt) - #expect(fetchTodosUseCaseSpy.queries.first?.sortOrder == .latest) - #expect(fetchTodosUseCaseSpy.queries.first?.pageSize == 100) } @MainActor @@ -70,14 +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) } struct HomeFetchDataContext { let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy - let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy } func makeHomeFetchDataContext() -> HomeFetchDataContext { @@ -96,49 +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 - ) - return HomeFetchDataContext( - fetchPreferencesUseCaseSpy: fetchPreferencesUseCaseSpy, - fetchTodosUseCaseSpy: fetchTodosUseCaseSpy + 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 + updatePreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCaseSpy() ) } diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift index 4cb2ca39..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,16 +41,6 @@ final class UpdateTodoCategoryPreferencesUseCaseSpy: UpdateTodoCategoryPreferenc } } -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 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 d73d315d..5686cdd1 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift @@ -19,7 +19,6 @@ struct HomeStoreTestAdapter { private let clock: TestClock var preferences: [TodoCategoryItem] { store.state.preferences } - var recentTodos: [RecentTodoItem] { store.state.recentTodos } var isNetworkConnected: Bool { store.state.isNetworkConnected } var showContentPicker: Bool { store.state.showContentPicker } var showCategoryManage: Bool { @@ -30,7 +29,6 @@ struct HomeStoreTestAdapter { init( fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), - fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = HomeTrackAnalyticsEventUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil @@ -42,7 +40,6 @@ struct HomeStoreTestAdapter { } withDependencies: { $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase $0.homeUpdateTodoCategoryPreferencesUseCase = updatePreferencesUseCase - $0.homeFetchTodosUseCase = fetchTodosUseCase $0.homeNetworkConnectivityUseCase = networkConnectivityUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.continuousClock = clock @@ -111,31 +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 - ) -} diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index da880269..163185ee 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -16,14 +16,10 @@ struct HomeFeatureTests { func HomeFeature_fetchData는_홈_상태를_갱신한다() async throws { let context = makeHomeFetchDataContext() let adapter = HomeStoreTestAdapter( - fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, - fetchTodosUseCase: context.fetchTodosUseCaseSpy + fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy ) - try await verifyHomeFetchData( - adapter: adapter, - fetchTodosUseCaseSpy: context.fetchTodosUseCaseSpy - ) + try await verifyHomeFetchData(adapter: adapter) } @Test("HomeFeature tapTodoCategory는 editor를 지연 표시한다") @@ -39,7 +35,6 @@ struct HomeFeatureTests { let trackSpy = HomeTrackAnalyticsEventUseCaseSpy() let adapter = HomeStoreTestAdapter( fetchPreferencesUseCase: context.fetchPreferencesUseCaseSpy, - fetchTodosUseCase: context.fetchTodosUseCaseSpy, trackAnalyticsEventUseCase: trackSpy ) @@ -47,21 +42,19 @@ struct HomeFeatureTests { await adapter.todoEditorCreated() await waitUntil { - context.fetchTodosUseCaseSpy.queries.count == 1 + context.fetchPreferencesUseCaseSpy.executeCount == 1 && trackSpy.hasTrackedTodoCreate } #expect(!adapter.showTodoEditor) - #expect(adapter.recentTodos.map(\.id) == ["todo-1", "todo-2"]) } - @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( diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 399d971e..8e4dd417 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -383,7 +383,7 @@ } } }, - "home_recent_empty" : { + "profile_recent_empty" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -400,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" : "최근 활동" } } } 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/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index a8aa5059..95996a27 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -16,8 +16,12 @@ public struct ProfileView: View { @State private var store: StoreOf @State private var path = [ProfileRoute]() private let isSelected: Bool + private let windowEvent: TodoEditorWindowEvent - public init(isSelected: Bool) { + public init( + isSelected: Bool, + windowEvent: TodoEditorWindowEvent + ) { let store = Store(initialState: ProfileFeature.State()) { ProfileFeature() } @@ -27,6 +31,7 @@ public struct ProfileView: View { self._store = State(initialValue: store) self._settingsStore = State(initialValue: settingsStore) self.isSelected = isSelected + self.windowEvent = windowEvent } public var body: some View { @@ -35,9 +40,10 @@ public struct ProfileView: View { LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { Section { ProfileCard(store: store, isSelected: isSelected) - } header: { - titleBar - } + RecentActivityCard(store: store) { todoId in + path.append(.recentTodo(todoId)) + } + } header: { titleBar } } .padding(.horizontal, 16) } @@ -98,6 +104,12 @@ public struct ProfileView: View { ) { 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: @@ -426,9 +438,133 @@ private struct ProfileCard: View { } } +// 개발 활동 카드 +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) + } + } + } + } +} + enum ProfileRoute: Hashable { case settings case activity(String) + case recentTodo(String) case theme case pushNotification case account 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 { }