From 673114d708833ab79f65e7c7bac4b6d5e47716f9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 23:58:52 +0900 Subject: [PATCH 01/20] =?UTF-8?q?chore:=20HomeTab=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/{Home => }/Category/CategoryManageFeature.swift | 0 .../HomeTab/Sources/{Home => }/Category/CategoryManageView.swift | 0 .../HomeTab/Sources/{Home => }/Category/CheckBox.swift | 0 .../Sources/Home/{Home => }/HomeFeature+Dependencies.swift | 0 .../HomeTab/Sources/Home/{Home => }/HomeFeature+Effects.swift | 0 .../HomeTab/Sources/Home/{Home => }/HomeFeature.swift | 0 .../Presentation/HomeTab/Sources/Home/{Home => }/HomeView.swift | 0 .../HomeTab/Sources/{Home => }/HomeDependencyPreparation.swift | 0 .../HomeTab/Sources/{Home => }/Search/SearchFeature.swift | 0 .../HomeTab/Sources/{Home => }/Search/SearchRowItem.swift | 0 .../HomeTab/Sources/{Home => }/Search/SearchView.swift | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename Application/Presentation/HomeTab/Sources/{Home => }/Category/CategoryManageFeature.swift (100%) rename Application/Presentation/HomeTab/Sources/{Home => }/Category/CategoryManageView.swift (100%) rename Application/Presentation/HomeTab/Sources/{Home => }/Category/CheckBox.swift (100%) rename Application/Presentation/HomeTab/Sources/Home/{Home => }/HomeFeature+Dependencies.swift (100%) rename Application/Presentation/HomeTab/Sources/Home/{Home => }/HomeFeature+Effects.swift (100%) rename Application/Presentation/HomeTab/Sources/Home/{Home => }/HomeFeature.swift (100%) rename Application/Presentation/HomeTab/Sources/Home/{Home => }/HomeView.swift (100%) rename Application/Presentation/HomeTab/Sources/{Home => }/HomeDependencyPreparation.swift (100%) rename Application/Presentation/HomeTab/Sources/{Home => }/Search/SearchFeature.swift (100%) rename Application/Presentation/HomeTab/Sources/{Home => }/Search/SearchRowItem.swift (100%) rename Application/Presentation/HomeTab/Sources/{Home => }/Search/SearchView.swift (100%) diff --git a/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageFeature.swift b/Application/Presentation/HomeTab/Sources/Category/CategoryManageFeature.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageFeature.swift rename to Application/Presentation/HomeTab/Sources/Category/CategoryManageFeature.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift b/Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Category/CategoryManageView.swift rename to Application/Presentation/HomeTab/Sources/Category/CategoryManageView.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Category/CheckBox.swift b/Application/Presentation/HomeTab/Sources/Category/CheckBox.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Category/CheckBox.swift rename to Application/Presentation/HomeTab/Sources/Category/CheckBox.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Dependencies.swift rename to Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature+Effects.swift rename to Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Home/HomeFeature.swift rename to Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Home/HomeView.swift rename to Application/Presentation/HomeTab/Sources/Home/HomeView.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/HomeDependencyPreparation.swift rename to Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift b/Application/Presentation/HomeTab/Sources/Search/SearchFeature.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Search/SearchFeature.swift rename to Application/Presentation/HomeTab/Sources/Search/SearchFeature.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchRowItem.swift b/Application/Presentation/HomeTab/Sources/Search/SearchRowItem.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Search/SearchRowItem.swift rename to Application/Presentation/HomeTab/Sources/Search/SearchRowItem.swift diff --git a/Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift similarity index 100% rename from Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift rename to Application/Presentation/HomeTab/Sources/Search/SearchView.swift From d539b80b83267ee2421e9c452515e43786d1bf82 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 11 Sep 2026 23:59:49 +0900 Subject: [PATCH 02/20] =?UTF-8?q?chore:=20TodayTab=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TodayTab/Sources/{Today => }/TodayDependencyPreparation.swift | 0 .../TodayTab/Sources/{Today => }/TodayFeature+State.swift | 0 .../Presentation/TodayTab/Sources/{Today => }/TodayFeature.swift | 0 .../Presentation/TodayTab/Sources/{Today => }/TodayRoute.swift | 0 .../Presentation/TodayTab/Sources/{Today => }/TodayTodoItem.swift | 0 .../Presentation/TodayTab/Sources/{Today => }/TodayView.swift | 0 .../TodayTab/Tests/{Today => }/TodayFeatureTestAssertions.swift | 0 .../TodayTab/Tests/{Today => }/TodayFeatureTestDoubles.swift | 0 .../TodayTab/Tests/{Today => }/TodayFeatureTestSpies.swift | 0 .../TodayTab/Tests/{Today => }/TodayFeatureTests.swift | 0 10 files changed, 0 insertions(+), 0 deletions(-) rename Application/Presentation/TodayTab/Sources/{Today => }/TodayDependencyPreparation.swift (100%) rename Application/Presentation/TodayTab/Sources/{Today => }/TodayFeature+State.swift (100%) rename Application/Presentation/TodayTab/Sources/{Today => }/TodayFeature.swift (100%) rename Application/Presentation/TodayTab/Sources/{Today => }/TodayRoute.swift (100%) rename Application/Presentation/TodayTab/Sources/{Today => }/TodayTodoItem.swift (100%) rename Application/Presentation/TodayTab/Sources/{Today => }/TodayView.swift (100%) rename Application/Presentation/TodayTab/Tests/{Today => }/TodayFeatureTestAssertions.swift (100%) rename Application/Presentation/TodayTab/Tests/{Today => }/TodayFeatureTestDoubles.swift (100%) rename Application/Presentation/TodayTab/Tests/{Today => }/TodayFeatureTestSpies.swift (100%) rename Application/Presentation/TodayTab/Tests/{Today => }/TodayFeatureTests.swift (100%) diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayDependencyPreparation.swift b/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift similarity index 100% rename from Application/Presentation/TodayTab/Sources/Today/TodayDependencyPreparation.swift rename to Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayFeature+State.swift b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift similarity index 100% rename from Application/Presentation/TodayTab/Sources/Today/TodayFeature+State.swift rename to Application/Presentation/TodayTab/Sources/TodayFeature+State.swift diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayFeature.swift b/Application/Presentation/TodayTab/Sources/TodayFeature.swift similarity index 100% rename from Application/Presentation/TodayTab/Sources/Today/TodayFeature.swift rename to Application/Presentation/TodayTab/Sources/TodayFeature.swift diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayRoute.swift b/Application/Presentation/TodayTab/Sources/TodayRoute.swift similarity index 100% rename from Application/Presentation/TodayTab/Sources/Today/TodayRoute.swift rename to Application/Presentation/TodayTab/Sources/TodayRoute.swift diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayTodoItem.swift b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift similarity index 100% rename from Application/Presentation/TodayTab/Sources/Today/TodayTodoItem.swift rename to Application/Presentation/TodayTab/Sources/TodayTodoItem.swift diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift similarity index 100% rename from Application/Presentation/TodayTab/Sources/Today/TodayView.swift rename to Application/Presentation/TodayTab/Sources/TodayView.swift diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestAssertions.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift similarity index 100% rename from Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestAssertions.swift rename to Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestDoubles.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift similarity index 100% rename from Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestDoubles.swift rename to Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestSpies.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift similarity index 100% rename from Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestSpies.swift rename to Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift similarity index 100% rename from Application/Presentation/TodayTab/Tests/Today/TodayFeatureTests.swift rename to Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift From a4c6713bdb4592f284851b29cfbaa377b5b14e58 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 00:55:07 +0900 Subject: [PATCH 03/20] =?UTF-8?q?refactor:=20=EC=83=88=EB=A1=9C=EC=9A=B4?= =?UTF-8?q?=20TodayView=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TodayTab/Sources/TodayFeature+State.swift | 188 +++++++++++++ .../TodayTab/Sources/TodayFeature.swift | 225 ++++++++-------- .../TodayTab/Sources/TodayTodoItem.swift | 4 + .../TodayTab/Sources/TodayView.swift | 72 +++-- .../Tests/TodayFeatureSupportTests.swift | 214 +++++++++++++++ .../Tests/TodayFeatureTestAssertions.swift | 31 ++- .../Tests/TodayFeatureTestDoubles.swift | 44 ++- .../Tests/TodayFeatureTestSpies.swift | 15 +- .../TodayTab/Tests/TodayFeatureTests.swift | 252 ++++++++++++++---- 9 files changed, 836 insertions(+), 209 deletions(-) create mode 100644 Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift index 5b4b9e9e..a9bb0b3d 100644 --- a/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift +++ b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift @@ -7,8 +7,196 @@ import Core import Foundation +import PresentationShared extension TodayFeature { + struct TodayAchievement: Equatable { + enum Status: Equatable { + case empty + case inProgress + case completed + } + + let completedCount: Int + let totalCount: Int + + var progress: Double { + guard totalCount != 0 else { return 0 } + return Double(completedCount) / Double(totalCount) + } + + var status: Status { + guard totalCount != 0 else { return .empty } + return completedCount == totalCount ? .completed : .inProgress + } + } + + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var todos: [TodayTodoItem] = [] + var completedTodayTodos: [TodayTodoItem] = [] + var selectedSectionScope: SectionScope = .all + var displayOptions: TodayDisplayOptions + var loading = LoadingFeature.State() + var todayInterval: DateInterval + var isIncompleteDataLoaded = false + var isCompletedTodayDataLoaded = false + + init( + displayOptions: TodayDisplayOptions = .default, + now: Date = Date() + ) { + self.displayOptions = displayOptions + self.todayInterval = TodayFeature.dayInterval(containing: now) + } + + var isLoading: Bool { + loading.isLoading + } + + var isTodayDataLoaded: Bool { + isIncompleteDataLoaded && isCompletedTodayDataLoaded + } + + var todayTodos: [TodayTodoItem] { + guard isTodayDataLoaded else { return [] } + return TodayFeature.todayTodos( + incompleteTodos: todos, + completedTodos: completedTodayTodos, + interval: todayInterval + ) + } + + var todayAchievement: TodayAchievement? { + guard isTodayDataLoaded else { return nil } + return TodayFeature.achievement( + incompleteTodos: todos, + completedTodos: completedTodayTodos, + interval: todayInterval + ) + } + + var sections: [SectionContent] { + let now = Date() + let items = TodayFeature.groupedSectionItems( + from: TodayFeature.displayedTodos( + todos: todos, + displayOptions: displayOptions + ), + now: now + ) + + switch selectedSectionScope { + case .all: + return + TodayFeature.makeSection( + category: .focused, + title: String(localized: "today_section_focused", bundle: PresentationResources.bundle), + items: items.focused + ) + + TodayFeature.makeSection( + category: .overdue, + title: String(localized: "today_section_overdue", bundle: PresentationResources.bundle), + items: items.overdue + ) + + TodayFeature.makeSection( + category: .dueSoon, + title: String.localizedStringWithFormat( + String(localized: "today_section_due_soon_format", bundle: PresentationResources.bundle), + Int64(TodayFeature.upcomingWindowDays) + ), + items: items.dueSoon + ) + + TodayFeature.makeSection( + category: .later, + title: String(localized: "today_section_later", bundle: PresentationResources.bundle), + items: items.later + ) + + TodayFeature.makeSection( + category: .unscheduled, + title: String(localized: "today_section_unscheduled", bundle: PresentationResources.bundle), + items: items.unscheduled + ) + case .focused: + return TodayFeature.makeSection( + category: .focused, + title: String(localized: "today_section_focused", bundle: PresentationResources.bundle), + items: items.focused + ) + case .overdue: + return TodayFeature.makeSection( + category: .overdue, + title: String(localized: "today_section_overdue", bundle: PresentationResources.bundle), + items: items.overdue + ) + case .dueSoon: + return TodayFeature.makeSection( + category: .dueSoon, + title: String.localizedStringWithFormat( + String(localized: "today_section_due_soon_format", bundle: PresentationResources.bundle), + Int64(TodayFeature.upcomingWindowDays) + ), + items: items.dueSoon + ) + } + } + + var summaryCounts: [SectionScope: Int] { + let now = Date() + return Dictionary( + uniqueKeysWithValues: SectionScope.allCases.map { scope in + ( + scope, + TodayFeature.summaryValue( + for: scope, + todos: todos, + displayOptions: displayOptions, + now: now + ) + ) + } + ) + } + } + + static func dayInterval( + containing date: Date, + calendar: Calendar = .autoupdatingCurrent + ) -> DateInterval { + calendar.dateInterval(of: .day, for: date) ?? DateInterval(start: date, duration: 0) + } + + static func isDue(_ item: TodayTodoItem, in interval: DateInterval) -> Bool { + guard let dueDate = item.dueDate else { return false } + return interval.start <= dueDate && dueDate < interval.end + } + + static func todayTodos( + incompleteTodos: [TodayTodoItem], + completedTodos: [TodayTodoItem], + interval: DateInterval + ) -> [TodayTodoItem] { + incompleteTodos.filter { isDue($0, in: interval) } + + completedTodos.filter { isDue($0, in: interval) } + } + + static func achievement( + incompleteTodos: [TodayTodoItem], + completedTodos: [TodayTodoItem], + interval: DateInterval + ) -> TodayAchievement { + let todos = todayTodos( + incompleteTodos: incompleteTodos, + completedTodos: completedTodos, + interval: interval + ) + return TodayAchievement( + completedCount: todos.filter(\.isCompleted).count, + totalCount: todos.count + ) + } + static func summaryValue( for scope: SectionScope, todos: [TodayTodoItem], diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature.swift b/Application/Presentation/TodayTab/Sources/TodayFeature.swift index f0a9d024..24085543 100644 --- a/Application/Presentation/TodayTab/Sources/TodayFeature.swift +++ b/Application/Presentation/TodayTab/Sources/TodayFeature.swift @@ -42,110 +42,12 @@ struct TodayFeature { var unscheduled: [TodayTodoItem] = [] } - @ObservableState - struct State: Equatable { - @Presents var alert: AlertState? - var todos: [TodayTodoItem] = [] - var selectedSectionScope: SectionScope = .all - var displayOptions: TodayDisplayOptions - var loading = LoadingFeature.State() - - init(displayOptions: TodayDisplayOptions = .default) { - self.displayOptions = displayOptions - } - - var isLoading: Bool { - loading.isLoading - } - - var sections: [SectionContent] { - let now = Date() - let items = TodayFeature.groupedSectionItems( - from: TodayFeature.displayedTodos( - todos: todos, - displayOptions: displayOptions - ), - now: now - ) - - switch selectedSectionScope { - case .all: - return - TodayFeature.makeSection( - category: .focused, - title: String(localized: "today_section_focused", bundle: PresentationResources.bundle), - items: items.focused - ) - + TodayFeature.makeSection( - category: .overdue, - title: String(localized: "today_section_overdue", bundle: PresentationResources.bundle), - items: items.overdue - ) - + TodayFeature.makeSection( - category: .dueSoon, - title: String.localizedStringWithFormat( - String(localized: "today_section_due_soon_format", bundle: PresentationResources.bundle), - Int64(TodayFeature.upcomingWindowDays) - ), - items: items.dueSoon - ) - + TodayFeature.makeSection( - category: .later, - title: String(localized: "today_section_later", bundle: PresentationResources.bundle), - items: items.later - ) - + TodayFeature.makeSection( - category: .unscheduled, - title: String(localized: "today_section_unscheduled", bundle: PresentationResources.bundle), - items: items.unscheduled - ) - case .focused: - return TodayFeature.makeSection( - category: .focused, - title: String(localized: "today_section_focused", bundle: PresentationResources.bundle), - items: items.focused - ) - case .overdue: - return TodayFeature.makeSection( - category: .overdue, - title: String(localized: "today_section_overdue", bundle: PresentationResources.bundle), - items: items.overdue - ) - case .dueSoon: - return TodayFeature.makeSection( - category: .dueSoon, - title: String.localizedStringWithFormat( - String(localized: "today_section_due_soon_format", bundle: PresentationResources.bundle), - Int64(TodayFeature.upcomingWindowDays) - ), - items: items.dueSoon - ) - } - } - - var summaryCounts: [SectionScope: Int] { - let now = Date() - return Dictionary( - uniqueKeysWithValues: SectionScope.allCases.map { scope in - ( - scope, - TodayFeature.summaryValue( - for: scope, - todos: todos, - displayOptions: displayOptions, - now: now - ) - ) - } - ) - } - } - enum Action: BindableAction, Equatable { case alert(PresentationAction) case binding(BindingAction) case refresh case fetchData + case checkCurrentDate(Date) case setSectionScope(SectionScope) case resetDisplayOptions case completeTodo(TodayTodoItem) @@ -155,7 +57,12 @@ struct TodayFeature { enum StoreAction: Equatable { case setAlert - case setTodos([TodayTodoItem]) + case setTodos( + incomplete: [TodayTodoItem], + completedToday: [TodayTodoItem], + interval: DateInterval + ) + case setCompletedTodayTodos([TodayTodoItem], interval: DateInterval) case updateTodo(TodayTodoItem) case removeTodo(String) } @@ -166,6 +73,7 @@ struct TodayFeature { @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase @Dependency(\.updateTodayDisplayOptionsUseCase) var updateTodayDisplayOptionsUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.date.now) var now static let pageSize = 20 static let upcomingWindowDays = 7 @@ -186,9 +94,22 @@ struct TodayFeature { case .binding: break case .refresh: - return fetchTodosEffect(showsIndicator: false) + let interval = prepareTodayInterval(state: &state, now: now) + return fetchTodosEffect(interval: interval, showsIndicator: false) case .fetchData: - return fetchTodosEffect() + let interval = prepareTodayInterval(state: &state, now: now) + return fetchTodosEffect(interval: interval) + case .checkCurrentDate(let date): + let interval = Self.dayInterval(containing: date) + guard state.todayInterval != interval else { break } + let requiresFullFetch = !state.isIncompleteDataLoaded + state.todayInterval = interval + state.completedTodayTodos = [] + state.isCompletedTodayDataLoaded = false + if requiresFullFetch { + return fetchTodosEffect(interval: interval, showsIndicator: false) + } + return fetchCompletedTodayTodosEffect(interval: interval) case .setSectionScope(let scope): if state.selectedSectionScope == scope, scope != .all { state.selectedSectionScope = .all @@ -204,16 +125,39 @@ struct TodayFeature { return togglePinnedEffect(item) case .store(.setAlert): state.alert = Self.alertState() - case .store(.setTodos(let todos)): - state.todos = todos + case .store(.setTodos(let incomplete, let completedToday, let interval)): + guard state.todayInterval == interval else { break } + state.todos = incomplete + state.isIncompleteDataLoaded = true + state.completedTodayTodos = completedToday + state.isCompletedTodayDataLoaded = true + case .store(.setCompletedTodayTodos(let todos, let interval)): + guard state.todayInterval == interval else { break } + state.completedTodayTodos = todos + state.isCompletedTodayDataLoaded = true case .store(.updateTodo(let item)): - if let index = state.todos.firstIndex(where: { $0.id == item.id }) { - state.todos[index] = item + if item.isCompleted { + state.todos.removeAll { $0.id == item.id } + if Self.isDue(item, in: state.todayInterval) { + if let index = state.completedTodayTodos.firstIndex(where: { $0.id == item.id }) { + state.completedTodayTodos[index] = item + } else { + state.completedTodayTodos.append(item) + } + } else { + state.completedTodayTodos.removeAll { $0.id == item.id } + } } else { - state.todos.append(item) + state.completedTodayTodos.removeAll { $0.id == item.id } + if let index = state.todos.firstIndex(where: { $0.id == item.id }) { + state.todos[index] = item + } else { + state.todos.append(item) + } } case .store(.removeTodo(let todoId)): state.todos.removeAll { $0.id == todoId } + state.completedTodayTodos.removeAll { $0.id == todoId } case .loading: break } @@ -257,7 +201,19 @@ private enum UpdateTodayDisplayOptionsUseCaseKey: DependencyKey { } private extension TodayFeature { - func fetchTodosEffect(showsIndicator: Bool = true) -> Effect { + func prepareTodayInterval(state: inout State, now: Date) -> DateInterval { + let interval = Self.dayInterval(containing: now) + guard state.todayInterval != interval else { return interval } + state.todayInterval = interval + state.completedTodayTodos = [] + state.isCompletedTodayDataLoaded = false + return interval + } + + func fetchTodosEffect( + interval: DateInterval, + showsIndicator: Bool = true + ) -> Effect { .run { [fetchTodosUseCase] send in if showsIndicator { await send(.loading(.begin(target: .default, mode: .delayed))) @@ -285,9 +241,18 @@ private extension TodayFeature { ), cursor: nil ) + async let completedTodayTodosPage = fetchTodosUseCase.execute( + Self.completedTodayQuery(interval: interval), + cursor: nil + ) let todosWithDueDate = try await todosWithDueDatePage.items.compactMap(TodayTodoItem.init(from:)) let todosWithoutDueDate = try await todosWithoutDueDatePage.items.compactMap(TodayTodoItem.init(from:)) - await send(.store(.setTodos(todosWithDueDate + todosWithoutDueDate))) + let completedTodayTodos = try await completedTodayTodosPage.items.compactMap(TodayTodoItem.init(from:)) + await send(.store(.setTodos( + incomplete: todosWithDueDate + todosWithoutDueDate, + completedToday: completedTodayTodos, + interval: interval + ))) if showsIndicator { await send(.loading(.end(target: .default, mode: .delayed))) } @@ -300,6 +265,34 @@ private extension TodayFeature { } } + func fetchCompletedTodayTodosEffect(interval: DateInterval) -> Effect { + .run { [fetchTodosUseCase] send in + do { + let page = try await fetchTodosUseCase.execute( + Self.completedTodayQuery(interval: interval), + cursor: nil + ) + let todos = page.items.compactMap(TodayTodoItem.init(from:)) + await send(.store(.setCompletedTodayTodos(todos, interval: interval))) + } catch { + await send(.store(.setAlert)) + } + } + } + + static func completedTodayQuery(interval: DateInterval) -> TodoQuery { + TodoQuery( + completionFilter: .completed, + dueDateFilter: .withDueDate, + sortDateFrom: interval.start, + sortDateTo: interval.end, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: Self.pageSize, + fetchAllPages: true + ) + } + func updateDisplayOptionsEffect(_ options: TodayDisplayOptions) -> Effect { .run { [updateTodayDisplayOptionsUseCase] _ in updateTodayDisplayOptionsUseCase.execute(options) @@ -307,17 +300,21 @@ private extension TodayFeature { } func completeTodoEffect(_ item: TodayTodoItem) -> Effect { - .run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase] send in + .run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase, now] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { var todo = try await fetchTodoByIdUseCase.execute(item.id) - let now = Date() todo.isCompleted = true todo.completedAt = now todo.updatedAt = now try await upsertTodoUseCase.execute(todo) trackAnalyticsEventUseCase.execute(.todoComplete) - await send(.store(.removeTodo(todo.id))) + guard let item = TodayTodoItem(from: todo) else { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.store(.setAlert)) + return + } + await send(.store(.updateTodo(item))) await send(.loading(.end(target: .default, mode: .delayed))) } catch { await send(.loading(.end(target: .default, mode: .delayed))) @@ -327,12 +324,12 @@ private extension TodayFeature { } func togglePinnedEffect(_ item: TodayTodoItem) -> Effect { - .run { [fetchTodoByIdUseCase, upsertTodoUseCase] send in + .run { [fetchTodoByIdUseCase, upsertTodoUseCase, now] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { var todo = try await fetchTodoByIdUseCase.execute(item.id) todo.isPinned.toggle() - todo.updatedAt = Date() + todo.updatedAt = now try await upsertTodoUseCase.execute(todo) guard let todayTodoItem = TodayTodoItem(from: todo) else { await send(.loading(.end(target: .default, mode: .delayed))) diff --git a/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift index 908a6969..073f5954 100644 --- a/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift +++ b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift @@ -12,8 +12,10 @@ public struct TodayTodoItem: Identifiable, Hashable { public let id: String public let number: Int public let title: String + public let content: String public let tags: [String] public let isPinned: Bool + public let isCompleted: Bool public let updatedAt: Date public let dueDate: Date? public let category: TodoCategory @@ -22,8 +24,10 @@ public struct TodayTodoItem: Identifiable, Hashable { self.id = todo.id self.number = todo.number self.title = todo.title + self.content = todo.content self.tags = todo.tags self.isPinned = todo.isPinned + self.isCompleted = todo.isCompleted self.updatedAt = todo.updatedAt self.dueDate = todo.dueDate self.category = todo.category diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 2c309d6d..0f4f3cbe 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -32,34 +32,62 @@ public struct TodayView: View { self.windowEvent = windowEvent } +// public var body: some View { +// 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) +// } +// } +// .prominentAlert(store, state: \.alert, action: \.alert) +// .overlay { +// if store.isLoading { +// LoadingView() +// } +// } +// } + public var body: some View { NavigationStack(path: $path) { - List { - summarySection - if store.sections.isEmpty, !store.isLoading { - emptySection - } else { - ForEach(store.sections) { section in - todoSection(section.title, items: section.items) + ScrollView { + LazyVStack(pinnedViews: [.sectionHeaders]) { + Section { + + } header: { + } } } - .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) - } } - .prominentAlert(store, state: \.alert, action: \.alert) - .overlay { - if store.isLoading { - LoadingView() + } + + private var topBar: some View { + VStack { + HStack { + Text("오늘 할 일 달성") + Text("진행 중") + Spacer() + HStack(spacing: 8) { +// let todayTodos = store.todos.filter { $0.dueDate.} +// Text(store.todos.filter { $0.}) + } } } } diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift new file mode 100644 index 00000000..98909cbd --- /dev/null +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift @@ -0,0 +1,214 @@ +// +// TodayFeatureSupportTests.swift +// TodayTabTests +// +// Created by opfic on 9/12/26. +// + +import Testing +import Foundation +import Core +import Domain +@testable import TodayTab + +@MainActor +struct TodayFeatureSupportTests { + @Test("TodayTodoItem은 예정 화면에 필요한 완료 상태와 내용을 보존한다") + func TodayTodoItem은_예정_화면에_필요한_완료_상태와_내용을_보존한다() throws { + let todo = makeTodayTodo( + isCompleted: true, + title: "제목", + content: "설명" + ) + + let item = try #require(TodayTodoItem(from: todo)) + + #expect(item.isCompleted) + #expect(item.title == "제목") + #expect(item.content == "설명") + } + + @Test("TodayFeature achievement는 오늘 마감 Todo만 집계한다") + func TodayFeature_achievement는_오늘_마감_Todo만_집계한다() throws { + let now = try #require(makeAchievementFixedNow()) + let calendar = Calendar.current + let interval = TodayFeature.dayInterval(containing: now, calendar: calendar) + let yesterday = try #require(calendar.date(byAdding: .day, value: -1, to: now)) + let tomorrow = try #require(calendar.date(byAdding: .day, value: 1, to: now)) + let incompleteTodos = [ + TodayTodoItem(from: makeTodayTodo(id: "incomplete-today", dueDate: now)), + TodayTodoItem(from: makeTodayTodo(id: "without-due-date", dueDate: nil)), + TodayTodoItem(from: makeTodayTodo(id: "tomorrow", dueDate: tomorrow)) + ].compactMap { $0 } + let completedTodos = [ + TodayTodoItem(from: makeTodayTodo(id: "completed-today", isCompleted: true, dueDate: now)), + TodayTodoItem(from: makeTodayTodo(id: "completed-yesterday", isCompleted: true, dueDate: yesterday)) + ].compactMap { $0 } + + let achievement = TodayFeature.achievement( + incompleteTodos: incompleteTodos, + completedTodos: completedTodos, + interval: interval + ) + let todayTodos = TodayFeature.todayTodos( + incompleteTodos: incompleteTodos, + completedTodos: completedTodos, + interval: interval + ) + + #expect(achievement.completedCount == 1) + #expect(achievement.totalCount == 2) + #expect(achievement.progress == 0.5) + #expect(achievement.status == .inProgress) + #expect(todayTodos.map(\.id) == ["incomplete-today", "completed-today"]) + } + + @Test("TodayFeature achievement는 대상 없음과 전체 완료 상태를 구분한다") + func TodayFeature_achievement는_대상_없음과_전체_완료_상태를_구분한다() throws { + let now = try #require(makeAchievementFixedNow()) + let interval = TodayFeature.dayInterval(containing: now) + let empty = TodayFeature.achievement( + incompleteTodos: [], + completedTodos: [], + interval: interval + ) + let completed = TodayFeature.achievement( + incompleteTodos: [], + completedTodos: [ + try #require(TodayTodoItem(from: makeTodayTodo(isCompleted: true, dueDate: now))) + ], + interval: interval + ) + + #expect(empty.completedCount == 0) + #expect(empty.totalCount == 0) + #expect(empty.progress == 0) + #expect(empty.status == .empty) + #expect(completed.completedCount == 1) + #expect(completed.totalCount == 1) + #expect(completed.progress == 1) + #expect(completed.status == .completed) + } + + @Test("TodayFeature dayInterval은 일광 절약 시간에도 다음 달력 날짜를 경계로 사용한다") + func TodayFeature_dayInterval은_일광_절약_시간에도_다음_달력_날짜를_경계로_사용한다() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 8, hour: 12))) + + let interval = TodayFeature.dayInterval(containing: date, calendar: calendar) + + #expect(interval.duration == 23 * 60 * 60) + #expect(calendar.component(.day, from: interval.start) == 8) + #expect(calendar.component(.day, from: interval.end) == 9) + } + + @Test("TodayFeature setSectionScope는 동일 탭 재선택 시 all로 되돌린다") + func TodayFeature_setSectionScope는_동일_탭_재선택_시_all로_되돌린다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) + + try await verifyTodaySectionScopeToggle(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @Test("TodayFeature displayOptions 변경은 필터링 결과와 저장 상태를 갱신한다") + func TodayFeature_displayOptions_변경은_필터링_결과와_저장_상태를_갱신한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let updateSpy = TodayUpdateDisplayOptionsUseCaseSpy() + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + updateDisplayOptionsUseCase: updateSpy + ) + + try await verifyTodayDisplayOptions( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + updateDisplayOptionsUseCaseSpy: updateSpy + ) + } + + @Test("TodayFeature togglePinned는 Todo를 갱신하고 섹션을 다시 계산한다") + func TodayFeature_togglePinned는_Todo를_갱신하고_섹션을_다시_계산한다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) + let upsertSpy = TodayUpsertTodoUseCaseSpy() + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy + ) + + try await verifyTodayTogglePinned( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + fetchTodoByIdUseCaseSpy: fetchByIdSpy, + upsertTodoUseCaseSpy: upsertSpy + ) + } + + @Test("TodayFeature completeTodo는 Todo를 제거하고 완료 이벤트를 남긴다") + func TodayFeature_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { + let todos = makeTodaySectionTodos() + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), + .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + ] + ) + let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) + let upsertSpy = TodayUpsertTodoUseCaseSpy() + let trackSpy = TodayTrackAnalyticsEventUseCaseSpy() + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy, + trackAnalyticsEventUseCase: trackSpy + ) + + try await verifyTodayCompleteTodo( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + fetchTodoByIdUseCaseSpy: fetchByIdSpy, + upsertTodoUseCaseSpy: upsertSpy, + trackAnalyticsEventUseCaseSpy: trackSpy + ) + } + + @Test("TodayFeature fetchData 실패는 에러 표시 상태를 만든다") + func TodayFeature_fetchData_실패는_에러_표시_상태를_만든다() async { + let fetchSpy = TodayFetchTodosUseCaseSpy() + fetchSpy.error = TodayTestError.failure + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) + + await verifyTodayFetchFailureShowsAlert(adapter: adapter) + } +} + +private func makeAchievementFixedNow() -> Date? { + Calendar.current.date( + from: DateComponents( + year: 2026, + month: 6, + day: 14, + hour: 12 + ) + ) +} diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift index 059f1950..850f5e2e 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift @@ -32,25 +32,32 @@ func verifyTodayFetchData( await adapter.fetchData() await waitUntilTodayMainActor { - adapter.todos.count == 5 + adapter.todos.count == 5 && adapter.isTodayDataLoaded } let queries = await fetchUseCaseSpy.calledQueries() - let queriesByDueDateFilter = Dictionary( - uniqueKeysWithValues: queries.map { ($0.dueDateFilter, $0) } - ) + let incompleteDueDateQuery = queries.first { + $0.completionFilter == .incomplete && $0.dueDateFilter == .withDueDate + } + let incompleteWithoutDueDateQuery = queries.first { + $0.completionFilter == .incomplete && $0.dueDateFilter == .withoutDueDate + } + let completedTodayQuery = queries.first { $0.completionFilter == .completed } let cursors = await fetchUseCaseSpy.calledCursors() - #expect(queries.count == 2) - #expect(Set(queries.map(\.dueDateFilter)) == Set([.withDueDate, .withoutDueDate])) - #expect(queries.allSatisfy { $0.completionFilter == .incomplete }) - #expect(queriesByDueDateFilter[.withDueDate]?.sortTarget == .dueDate) - #expect(queriesByDueDateFilter[.withDueDate]?.sortOrder == .oldest) - #expect(queriesByDueDateFilter[.withoutDueDate]?.sortTarget == .updatedAt) - #expect(queriesByDueDateFilter[.withoutDueDate]?.sortOrder == .latest) + #expect(queries.count == 3) + #expect(incompleteDueDateQuery?.sortTarget == .dueDate) + #expect(incompleteDueDateQuery?.sortOrder == .oldest) + #expect(incompleteWithoutDueDateQuery?.sortTarget == .updatedAt) + #expect(incompleteWithoutDueDateQuery?.sortOrder == .latest) + #expect(completedTodayQuery?.dueDateFilter == .withDueDate) + #expect(completedTodayQuery?.sortDateFrom == adapter.todayInterval.start) + #expect(completedTodayQuery?.sortDateTo == adapter.todayInterval.end) + #expect(completedTodayQuery?.sortTarget == .dueDate) + #expect(completedTodayQuery?.sortOrder == .oldest) #expect(queries.map(\.pageSize).allSatisfy { $0 == 20 }) #expect(queries.map(\.fetchAllPages).allSatisfy { $0 }) - #expect(cursors.count == 2) + #expect(cursors.count == 3) #expect(cursors.allSatisfy { $0 == nil }) #expect(adapter.todos.map(\.id) == ["focused", "overdue", "due-soon", "later", "unscheduled"]) #expect(adapter.summaryCounts == [ diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift index 127aabba..6f3febce 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift @@ -38,6 +38,11 @@ struct TodayDisplayedSection: Equatable { @MainActor protocol TodayStateDriving { var todos: [TodayTodoItem] { get } + var completedTodayTodos: [TodayTodoItem] { get } + var todayTodos: [TodayTodoItem] { get } + var todayAchievement: TodayFeature.TodayAchievement? { get } + var todayInterval: DateInterval { get } + var isTodayDataLoaded: Bool { get } var selectedSectionScope: TodayTestSectionScope { get } var displayOptions: TodayDisplayOptions { get } var showAlert: Bool { get } @@ -46,6 +51,7 @@ protocol TodayStateDriving { var summaryCounts: [TodayTestSectionScope: Int] { get } func fetchData() async + func checkCurrentDate(_ date: Date) async func setSectionScope(_ scope: TodayTestSectionScope) async func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async @@ -59,6 +65,11 @@ struct TodayStoreTestAdapter: TodayStateDriving { private let store: TestStoreOf var todos: [TodayTodoItem] { store.state.todos } + var completedTodayTodos: [TodayTodoItem] { store.state.completedTodayTodos } + var todayTodos: [TodayTodoItem] { store.state.todayTodos } + var todayAchievement: TodayFeature.TodayAchievement? { store.state.todayAchievement } + var todayInterval: DateInterval { store.state.todayInterval } + var isTodayDataLoaded: Bool { store.state.isTodayDataLoaded } var selectedSectionScope: TodayTestSectionScope { store.state.selectedSectionScope.testValue } var displayOptions: TodayDisplayOptions { store.state.displayOptions } var showAlert: Bool { store.state.alert != nil } @@ -79,11 +90,13 @@ struct TodayStoreTestAdapter: TodayStateDriving { fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase = TodayFetchDisplayOptionsUseCaseSpy(), updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase = TodayUpdateDisplayOptionsUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodayTrackAnalyticsEventUseCaseSpy(), + now: Date = Date(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { store = TestStore( initialState: TodayFeature.State( - displayOptions: fetchDisplayOptionsUseCase.execute() + displayOptions: fetchDisplayOptionsUseCase.execute(), + now: now ) ) { TodayFeature() @@ -93,6 +106,7 @@ struct TodayStoreTestAdapter: TodayStateDriving { $0.upsertTodoUseCase = upsertUseCase $0.updateTodayDisplayOptionsUseCase = updateDisplayOptionsUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.date.now = now $0.continuousClock = ContinuousClock() configureDependencies?(&$0) } @@ -104,6 +118,34 @@ struct TodayStoreTestAdapter: TodayStateDriving { await drainReceivedActions() } + func checkCurrentDate(_ date: Date) async { + await store.send(.checkCurrentDate(date)) + await drainReceivedActions() + } + + func receiveCompletedTodayTodos( + _ todos: [TodayTodoItem], + interval: DateInterval + ) async { + await store.send(.store(.setCompletedTodayTodos(todos, interval: interval))) + } + + func receiveTodos( + incomplete: [TodayTodoItem], + completedToday: [TodayTodoItem], + interval: DateInterval + ) async { + await store.send(.store(.setTodos( + incomplete: incomplete, + completedToday: completedToday, + interval: interval + ))) + } + + func receiveUpdatedTodo(_ item: TodayTodoItem) async { + await store.send(.store(.updateTodo(item))) + } + func setSectionScope(_ scope: TodayTestSectionScope) async { await store.send(.setSectionScope(scope.featureValue)) } diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift index 398ce3c0..43e8ee08 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift @@ -12,6 +12,7 @@ import Domain final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { var pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] + var completedTodayPage: TodoPage var error: Error? private let recorder = TodayFetchTodosUseCaseCallRecorder() @@ -19,9 +20,11 @@ final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { pagesByFilter: [TodoQuery.DueDateFilter: TodoPage] = [ .withDueDate: TodoPage(items: [], nextCursor: nil), .withoutDueDate: TodoPage(items: [], nextCursor: nil) - ] + ], + completedTodayPage: TodoPage = TodoPage(items: [], nextCursor: nil) ) { self.pagesByFilter = pagesByFilter + self.completedTodayPage = completedTodayPage } func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { @@ -31,6 +34,10 @@ final class TodayFetchTodosUseCaseSpy: FetchTodosUseCase { throw error } + if query.completionFilter == .completed { + return completedTodayPage + } + return pagesByFilter[query.dueDateFilter] ?? TodoPage(items: [], nextCursor: nil) } @@ -138,8 +145,10 @@ func makeTodayTodo( id: String = "todo-1", isPinned: Bool = false, isCompleted: Bool = false, + isChecked: Bool = false, number: Int = 1, title: String = "Todo", + content: String = "content", dueDate: Date? = nil ) -> Todo { let now = Date(timeIntervalSince1970: 0) @@ -147,10 +156,10 @@ func makeTodayTodo( id: id, isPinned: isPinned, isCompleted: isCompleted, - isChecked: false, + isChecked: isChecked, number: number, title: title, - content: "content", + content: content, createdAt: now, updatedAt: now, completedAt: isCompleted ? now : nil, diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift index 57b2aae0..9061f746 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation import Core +import Domain @testable import TodayTab @MainActor @@ -79,103 +80,240 @@ struct TodayFeatureTests { try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchSpy) } - @Test("TodayFeature setSectionScope는 동일 탭 재선택 시 all로 되돌린다") - func TodayFeature_setSectionScope는_동일_탭_재선택_시_all로_되돌린다() async throws { - let todos = makeTodaySectionTodos() + @Test("TodayFeature fetchData는 오늘 완료 Todo를 별도 조회해 달성 상태를 만든다") + func TodayFeature_fetchData는_오늘_완료_Todo를_별도_조회해_달성_상태를_만든다() async throws { + let now = try #require(makeFixedTodayNow()) + let incompleteToday = makeTodayTodo(id: "incomplete-today", dueDate: now) + let completedToday = makeTodayTodo(id: "completed-today", isCompleted: true, dueDate: now) let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) - ] + .withDueDate: TodoPage(items: [incompleteToday], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) + ], + completedTodayPage: TodoPage(items: [completedToday], nextCursor: nil) ) - let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy, now: now) - try await verifyTodaySectionScopeToggle(adapter: adapter, fetchUseCaseSpy: fetchSpy) + await adapter.fetchData() + + #expect(adapter.completedTodayTodos.map(\.id) == ["completed-today"]) + #expect(adapter.todayTodos.map(\.id) == ["incomplete-today", "completed-today"]) + #expect(adapter.todayAchievement?.completedCount == 1) + #expect(adapter.todayAchievement?.totalCount == 2) + #expect(adapter.summaryCounts[.all] == 1) } - @Test("TodayFeature displayOptions 변경은 필터링 결과와 저장 상태를 갱신한다") - func TodayFeature_displayOptions_변경은_필터링_결과와_저장_상태를_갱신한다() async throws { - let todos = makeTodaySectionTodos() + @Test("TodayFeature checkCurrentDate는 날짜가 바뀔 때만 오늘 완료 Todo를 다시 조회한다") + func TodayFeature_checkCurrentDate는_날짜가_바뀔_때만_오늘_완료_Todo를_다시_조회한다() async throws { + let now = try #require(makeFixedTodayNow()) + let calendar = Calendar.current + let nextDay = try #require(calendar.date(byAdding: .day, value: 1, to: now)) + let fetchSpy = TodayFetchTodosUseCaseSpy() + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy, now: now) + await adapter.fetchData() + + await adapter.checkCurrentDate(now) + let sameDayQueries = await fetchSpy.calledQueries() + let sameDayQueryCount = sameDayQueries.count + #expect(sameDayQueryCount == 3) + + fetchSpy.completedTodayPage = TodoPage( + items: [makeTodayTodo(id: "next-day", isCompleted: true, dueDate: nextDay)], + nextCursor: nil + ) + await adapter.checkCurrentDate(nextDay) + + let queries = await fetchSpy.calledQueries() + let lastQuery = try #require(queries.last) + #expect(queries.count == 4) + #expect(lastQuery.completionFilter == .completed) + #expect(lastQuery.sortDateFrom == adapter.todayInterval.start) + #expect(lastQuery.sortDateTo == adapter.todayInterval.end) + #expect(adapter.completedTodayTodos.map(\.id) == ["next-day"]) + #expect(adapter.todayAchievement?.completedCount == 1) + #expect(adapter.todayAchievement?.totalCount == 1) + } + + @Test("TodayFeature는 이전 날짜의 완료 조회 결과를 반영하지 않는다") + func TodayFeature는_이전_날짜의_완료_조회_결과를_반영하지_않는다() async throws { + let now = try #require(makeFixedTodayNow()) + let calendar = Calendar.current + let previousDay = try #require(calendar.date(byAdding: .day, value: -1, to: now)) + let adapter = TodayStoreTestAdapter(now: now) + let previousInterval = TodayFeature.dayInterval(containing: previousDay) + let staleItem = try #require(TodayTodoItem(from: makeTodayTodo( + id: "stale", + isCompleted: true, + dueDate: previousDay + ))) + + await adapter.receiveCompletedTodayTodos([staleItem], interval: previousInterval) + + #expect(adapter.completedTodayTodos.isEmpty) + #expect(!adapter.isTodayDataLoaded) + #expect(adapter.todayAchievement == nil) + } + + @Test("TodayFeature는 최초 조회 중 날짜가 바뀌면 새 날짜 전체 데이터를 다시 조회한다") + func TodayFeature는_최초_조회_중_날짜가_바뀌면_새_날짜_전체_데이터를_다시_조회한다() async throws { + let now = try #require(makeFixedTodayNow()) + let calendar = Calendar.current + let nextDay = try #require(calendar.date(byAdding: .day, value: 1, to: now)) + let previousInterval = TodayFeature.dayInterval(containing: now) + let incompleteTodo = makeTodayTodo(id: "next-incomplete", dueDate: nextDay) + let completedTodo = makeTodayTodo( + id: "next-completed", + isCompleted: true, + dueDate: nextDay + ) let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + .withDueDate: TodoPage(items: [incompleteTodo], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) + ], + completedTodayPage: TodoPage(items: [completedTodo], nextCursor: nil) + ) + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy, now: now) + + await adapter.checkCurrentDate(nextDay) + + let queries = await fetchSpy.calledQueries() + let queryCount = queries.count + #expect(queryCount == 3) + #expect(adapter.isTodayDataLoaded) + #expect(adapter.todayTodos.map(\.id) == ["next-incomplete", "next-completed"]) + #expect(adapter.todayAchievement?.completedCount == 1) + #expect(adapter.todayAchievement?.totalCount == 2) + + let staleItem = try #require(TodayTodoItem(from: makeTodayTodo( + id: "stale", + dueDate: nextDay + ))) + + await adapter.receiveTodos( + incomplete: [staleItem], + completedToday: [], + interval: previousInterval + ) + + #expect(adapter.isTodayDataLoaded) + #expect(adapter.todayTodos.map(\.id) == ["next-incomplete", "next-completed"]) + #expect(adapter.todayAchievement?.completedCount == 1) + #expect(adapter.todayAchievement?.totalCount == 2) + } + + @Test("TodayFeature completeTodo는 오늘 Todo를 완료 목록으로 한 번만 이동한다") + func TodayFeature_completeTodo는_오늘_Todo를_완료_목록으로_한_번만_이동한다() async throws { + let now = try #require(makeFixedTodayNow()) + let todo = makeTodayTodo(id: "today", dueDate: now) + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: TodoPage(items: [todo], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) ] ) - let updateSpy = TodayUpdateDisplayOptionsUseCaseSpy() + let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: [todo]) let adapter = TodayStoreTestAdapter( fetchUseCase: fetchSpy, - updateDisplayOptionsUseCase: updateSpy + fetchTodoByIdUseCase: fetchByIdSpy, + now: now ) + await adapter.fetchData() + let item = try #require(adapter.todos.first) - try await verifyTodayDisplayOptions( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - updateDisplayOptionsUseCaseSpy: updateSpy - ) + await adapter.completeTodo(item) + + let completedItem = try #require(adapter.completedTodayTodos.first) + await adapter.receiveUpdatedTodo(completedItem) + await adapter.receiveUpdatedTodo(completedItem) + + #expect(adapter.todos.isEmpty) + #expect(adapter.completedTodayTodos.map(\.id) == ["today"]) + #expect(adapter.todayAchievement?.completedCount == 1) + #expect(adapter.todayAchievement?.totalCount == 1) } - @Test("TodayFeature togglePinned는 Todo를 갱신하고 섹션을 다시 계산한다") - func TodayFeature_togglePinned는_Todo를_갱신하고_섹션을_다시_계산한다() async throws { - let todos = makeTodaySectionTodos() + @Test("TodayFeature completeTodo는 저장된 최신 마감일로 오늘 완료 목록을 갱신한다") + func TodayFeature_completeTodo는_저장된_최신_마감일로_오늘_완료_목록을_갱신한다() async throws { + let now = try #require(makeFixedTodayNow()) + let tomorrow = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) + let original = makeTodayTodo(id: "today", dueDate: now) + let latest = makeTodayTodo(id: "today", dueDate: tomorrow) let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + .withDueDate: TodoPage(items: [original], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) ] ) - let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) - let upsertSpy = TodayUpsertTodoUseCaseSpy() let adapter = TodayStoreTestAdapter( fetchUseCase: fetchSpy, - fetchTodoByIdUseCase: fetchByIdSpy, - upsertUseCase: upsertSpy + fetchTodoByIdUseCase: TodayFetchTodoByIdUseCaseSpy(todos: [latest]), + now: now ) + await adapter.fetchData() + let item = try #require(adapter.todos.first) - try await verifyTodayTogglePinned( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - fetchTodoByIdUseCaseSpy: fetchByIdSpy, - upsertTodoUseCaseSpy: upsertSpy - ) + await adapter.completeTodo(item) + + #expect(adapter.todos.isEmpty) + #expect(adapter.completedTodayTodos.isEmpty) + #expect(adapter.todayAchievement?.completedCount == 0) + #expect(adapter.todayAchievement?.totalCount == 0) } - @Test("TodayFeature completeTodo는 Todo를 제거하고 완료 이벤트를 남긴다") - func TodayFeature_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { - let todos = makeTodaySectionTodos() + @Test("TodayFeature completeTodo 실패는 오늘 달성 상태를 유지한다") + func TodayFeature_completeTodo_실패는_오늘_달성_상태를_유지한다() async throws { + let now = try #require(makeFixedTodayNow()) + let todo = makeTodayTodo(id: "today", dueDate: now) let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) + .withDueDate: TodoPage(items: [todo], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) ] ) - let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) let upsertSpy = TodayUpsertTodoUseCaseSpy() - let trackSpy = TodayTrackAnalyticsEventUseCaseSpy() + upsertSpy.error = TodayTestError.failure let adapter = TodayStoreTestAdapter( fetchUseCase: fetchSpy, - fetchTodoByIdUseCase: fetchByIdSpy, + fetchTodoByIdUseCase: TodayFetchTodoByIdUseCaseSpy(todos: [todo]), upsertUseCase: upsertSpy, - trackAnalyticsEventUseCase: trackSpy + now: now ) + await adapter.fetchData() + let item = try #require(adapter.todos.first) - try await verifyTodayCompleteTodo( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - fetchTodoByIdUseCaseSpy: fetchByIdSpy, - upsertTodoUseCaseSpy: upsertSpy, - trackAnalyticsEventUseCaseSpy: trackSpy - ) + await adapter.completeTodo(item) + + #expect(adapter.todos.map(\.id) == ["today"]) + #expect(adapter.completedTodayTodos.isEmpty) + #expect(adapter.todayAchievement?.completedCount == 0) + #expect(adapter.todayAchievement?.totalCount == 1) + #expect(adapter.showAlert) } - @Test("TodayFeature fetchData 실패는 에러 표시 상태를 만든다") - func TodayFeature_fetchData_실패는_에러_표시_상태를_만든다() async { - let fetchSpy = TodayFetchTodosUseCaseSpy() - fetchSpy.error = TodayTestError.failure - let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) + @Test("TodayFeature 달성 상태는 isChecked와 표시 옵션의 영향을 받지 않는다") + func TodayFeature_달성_상태는_isChecked와_표시_옵션의_영향을_받지_않는다() async throws { + let now = try #require(makeFixedTodayNow()) + let checkedTodo = makeTodayTodo(id: "checked", isChecked: true, dueDate: now) + let completedTodo = makeTodayTodo(id: "completed", isCompleted: true, dueDate: now) + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: TodoPage(items: [checkedTodo], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) + ], + completedTodayPage: TodoPage(items: [completedTodo], nextCursor: nil) + ) + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy, now: now) + await adapter.fetchData() - await verifyTodayFetchFailureShowsAlert(adapter: adapter) + await adapter.setDueDateVisibility(.withoutDueDateOnly) + await adapter.setFocusVisibility(.focusedOnly) + + #expect(adapter.todayAchievement?.completedCount == 1) + #expect(adapter.todayAchievement?.totalCount == 2) + #expect(adapter.todayAchievement?.status == .inProgress) } + } private func makeFixedTodayNow() -> Date? { From b431cf805b039a9894fd612a61fc39a08a05449f Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 01:35:09 +0900 Subject: [PATCH 04/20] =?UTF-8?q?ui:=20=ED=94=BC=EA=B7=B8=EB=A7=88=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localizable.xcstrings | 167 ++++- .../TodayTab/Sources/TodayView.swift | 662 +++++++++++------- 2 files changed, 566 insertions(+), 263 deletions(-) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 29323199..a3b81ec2 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -735,7 +735,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "투데이" + "value" : "오늘" } } } @@ -2032,6 +2032,91 @@ } } }, + "today_achievement_status_completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료" + } + } + } + }, + "today_achievement_status_empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tasks" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예정 없음" + } + } + } + }, + "today_achievement_status_in_progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중" + } + } + } + }, + "today_achievement_status_loading" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "불러오는 중" + } + } + } + }, + "today_achievement_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today's Progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 완료" + } + } + } + }, "today_complete_action" : { "extractionState" : "manual", "localizations" : { @@ -2066,6 +2151,23 @@ } } }, + "today_due_yesterday" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Due Yesterday" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어제까지" + } + } + } + }, "today_due_visibility_all" : { "extractionState" : "manual", "localizations" : { @@ -2265,7 +2367,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "집중할 일이 없어요." + "value" : "중요 Todo가 없어요." } } } @@ -2338,6 +2440,23 @@ } } }, + "today_pinned_first" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pinned First" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중요 항목 우선" + } + } + } + }, "today_section_due_soon_format" : { "extractionState" : "manual", "localizations" : { @@ -2367,7 +2486,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "집중할 일" + "value" : "중요 Todo" } } } @@ -2395,13 +2514,47 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Overdue" + "value" : "Overdue Tasks" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기한이 지난 Todo" + } + } + } + }, + "today_section_today" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Due Today" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘까지" + } + } + } + }, + "today_section_upcoming" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "지난 마감" + "value" : "다가오는 일정" } } } @@ -2418,7 +2571,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "일정 미정" + "value" : "기한 없음" } } } @@ -2486,7 +2639,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "집중" + "value" : "중요" } } } diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 0f4f3cbe..753eae12 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -11,6 +11,7 @@ import Domain import PresentationShared public struct TodayView: View { + @Environment(\.scenePhase) private var scenePhase @State private var path = [TodayRoute]() @State private var store: StoreOf private let isSelected: Bool @@ -32,198 +33,292 @@ public struct TodayView: View { self.windowEvent = windowEvent } -// public var body: some View { -// 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) -// } -// } -// .prominentAlert(store, state: \.alert, action: \.alert) -// .overlay { -// if store.isLoading { -// LoadingView() -// } -// } -// } - public var body: some View { NavigationStack(path: $path) { ScrollView { - LazyVStack(pinnedViews: [.sectionHeaders]) { + LazyVStack(alignment: .leading, spacing: 24, pinnedViews: [.sectionHeaders]) { Section { - + scopePicker + if displaySections.isEmpty, !store.isLoading { + emptyContent + .padding(.horizontal) + } else { + ForEach(displaySections) { section in + todoSection(section) + .padding(.horizontal) + } + } } header: { - + achievementCard } } + .padding(.bottom, 24) + } + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .background(Color.appBackground.ignoresSafeArea()) + .refreshable { await store.send(.refresh).finish() } + .navigationDestination(for: TodayRoute.self, destination: destination) + } + .onChange(of: isSelected, initial: true) { _, isSelected in + if isSelected { + store.send(.fetchData) + } + } + .onChange(of: scenePhase) { _, scenePhase in + if scenePhase == .active, isSelected { + store.send(.checkCurrentDate(Date())) + } + } + .task(id: isSelected) { + guard isSelected else { return } + await monitorDateChanges() + } + .prominentAlert(store, state: \.alert, action: \.alert) + .overlay { + if store.isLoading { + LoadingView() } } } private var topBar: some View { - VStack { - HStack { - Text("오늘 할 일 달성") - Text("진행 중") - Spacer() - HStack(spacing: 8) { -// let todayTodos = store.todos.filter { $0.dueDate.} -// Text(store.todos.filter { $0.}) + Text(String(localized: "nav_today", bundle: PresentationResources.bundle)) + .font(.largeTitle.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 8) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var achievementCard: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 10) { + Text(String( + localized: "today_achievement_title", + bundle: PresentationResources.bundle) + ) + .font(.title3.bold()) + + Text(achievementStatusTitle) + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.primaryContainer, in: .capsule) + + Spacer(minLength: 8) + + progressCount + filterMenu + } + + ProgressView(value: store.todayAchievement?.progress ?? 0) + .tint(Color.accent) + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + .padding(.horizontal) + .background(Color.appBackground) + } + + private var progressCount: some View { + Group { + if let achievement = store.todayAchievement { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text("\(achievement.completedCount)") + .font(.title2.bold()) + .foregroundStyle(Color.accent) + Text("/") + Text("\(achievement.totalCount)") } + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.textSecondary) + } else { + Text("-- / --") + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.textSecondary) } } } - 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 + private var filterMenu: some View { + Menu { + Picker( + String( + localized: "today_due_visibility_label", + bundle: PresentationResources.bundle + ), + selection: $store.displayOptions.dueDateVisibility + ) { + ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + + Toggle( + String( + localized: "today_pinned_only", + bundle: PresentationResources.bundle + ), + isOn: $store.displayOptions.isFocusedOnly ) - .id(item.id) + } label: { + Image(systemName: "ellipsis") + .font(.headline) + .foregroundStyle(Color.textSecondary) + .padding(8) } } - private var summarySection: some View { - Section { - ScrollView(.horizontal) { - let summaryCounts = store.summaryCounts - let selectedSectionScope = store.selectedSectionScope - - HStack(spacing: 12) { - ForEach(TodayFeature.SectionScope.allCases, id: \.self) { scope in - Button { - withAnimation(SwiftUI.Animation.easeInOut) { - _ = store.send(.setSectionScope(scope)) - } - } label: { - SummaryCard( - title: scope.title, - value: summaryCounts[scope, default: 0], - accentColor: scope.accentColor, - isSelected: selectedSectionScope == scope - ) + @ViewBuilder + private var scopePicker: some View { + let summaryCounts = store.summaryCounts + + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(TodayFeature.SectionScope.allCases, id: \.self) { scope in + let isSelected = store.selectedSectionScope == scope + Button { + withAnimation(.easeInOut) { + _ = store.send(.setSectionScope(scope)) + } + } label: { + HStack(spacing: 6) { + Text(scope.title) + Text("\(summaryCounts[scope, default: 0])") + .fontWeight(.bold) } - .buttonStyle(.plain) + .font(.callout) + .foregroundStyle(isSelected ? Color.accent : Color.textSecondary) } + .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .clear) } } - .scrollIndicators(.never) - .contentMargins(.horizontal, 16) } - .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) + .scrollIndicators(.hidden) + .contentMargins(.horizontal, 16, for: .scrollContent) } - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Picker( - String(localized: "today_due_visibility_label", bundle: PresentationResources.bundle), - selection: $store.displayOptions.dueDateVisibility - ) { - ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in - Text(option.title).tag(option) - } - } + private var displaySections: [TodayDisplaySection] { + guard store.selectedSectionScope == .all else { + return store.sections.map(TodayDisplaySection.init) + } - Toggle( - String(localized: "today_pinned_only", bundle: PresentationResources.bundle), - isOn: $store.displayOptions.isFocusedOnly - ) - .tint(.orange) + let todayItems = visibleTodayTodos + let todayItemIDs = Set(todayItems.filter { !$0.isCompleted }.map(\.id)) + let remainingSections = store.sections.compactMap { section -> TodayDisplaySection? in + let items = section.items.filter { !todayItemIDs.contains($0.id) } + guard !items.isEmpty else { return nil } + return TodayDisplaySection(section: section, items: items) + } + let order: [TodayFeature.SectionCategory] = [.overdue, .dueSoon, .focused, .later, .unscheduled] + var sections = order.compactMap { category in + remainingSections.first { $0.category == category } + } + + guard !todayItems.isEmpty else { return sections } + let todaySection = TodayDisplaySection(todayItems: todayItems) + let insertionIndex = sections.firstIndex { $0.category != .overdue } ?? sections.endIndex + sections.insert(todaySection, at: insertionIndex) + return sections + } - if store.displayOptions.focusVisibility == .focusedOnly { - Text(String(localized: "today_pinned_only_description", bundle: PresentationResources.bundle)) - .font(.caption) + private var visibleTodayTodos: [TodayTodoItem] { + let items = TodayFeature.displayedTodos( + todos: store.todayTodos, + displayOptions: store.displayOptions + ) + return items.filter(\.isPinned) + items.filter { !$0.isPinned } + } + + private func todoSection(_ section: TodayDisplaySection) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text(section.title) + .font(.title3.bold()) + .foregroundStyle(section.accentColor) + Text("\(section.items.count)") + .font(.callout.bold()) + .foregroundStyle(section.accentColor) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(section.accentColor.opacity(0.1), in: .capsule) + Spacer() + if section.id == .today { + Text(String(localized: "today_pinned_first", bundle: PresentationResources.bundle)) + .font(.callout) + .foregroundStyle(Color.textSecondary) + } + } + + LazyVStack(spacing: 12) { + ForEach(section.items) { item in + TodayTodoCard( + item: item, + onComplete: { store.send(.completeTodo(item)) }, + onTogglePinned: { store.send(.togglePinned(item)) } + ) } - } label: { - let options = store.displayOptions - Image(systemName: "line.3.horizontal.decrease.circle\(options == .default ? "" : ".fill")") } } } - private var emptySection: some View { - Section { - VStack(spacing: 8) { - Text(emptyStateContent.title) - .foregroundStyle(.primary) - Text(emptyStateContent.message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 28) + private var emptyContent: some View { + VStack(spacing: 8) { + Text(emptyStateContent.title) + .font(.headline) + Text(emptyStateContent.message) + .font(.callout) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .padding(.horizontal, 20) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) } } - @ViewBuilder - private func todoSection(_ title: String, items: [TodayTodoItem]) -> some View { - if !items.isEmpty { - Section { - ForEach(items) { item in - todoRow(item) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - Button { - store.send(.togglePinned(item)) - } label: { - Image(systemName: item.isPinned ? "star.slash" : "star.fill") - } - .tint(.orange) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - store.send(.completeTodo(item)) - } label: { - Label( - String( - localized: "today_complete_action", - bundle: PresentationResources.bundle - ), - systemImage: "checkmark" - ) - } - .tint(.green) - } - } - } header: { - Text(title) - .listRowInsets(EdgeInsets()) - } + private func destination(_ 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) } } - @ViewBuilder - private func todoRow(_ item: TodayTodoItem) -> some View { - NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { - TodayTodoRow(item: item) + private func monitorDateChanges() async { + while !Task.isCancelled { + let date = Date() + store.send(.checkCurrentDate(date)) + let interval = TodayFeature.dayInterval(containing: date) + let seconds = min(max(interval.end.timeIntervalSince(date), 1), 60) + do { + try await Task.sleep(for: .seconds(seconds)) + } catch { + return + } } - .todoDetailPreview(todoId: item.id) + } + + private var achievementStatusTitle: String { + store.todayAchievement?.status.title + ?? String(localized: "today_achievement_status_loading", bundle: PresentationResources.bundle) } private var emptyStateContent: EmptyStateContent { @@ -263,6 +358,149 @@ public struct TodayView: View { } } +private struct TodayDisplaySection: Identifiable { + enum Identifier: Hashable { + case today + case category(TodayFeature.SectionCategory) + } + + let id: Identifier + let category: TodayFeature.SectionCategory? + let title: String + let items: [TodayTodoItem] + let accentColor: Color + + init(_ section: TodayFeature.SectionContent) { + self.init(section: section, items: section.items) + } + + init(section: TodayFeature.SectionContent, items: [TodayTodoItem]) { + self.id = .category(section.category) + self.category = section.category + self.title = section.category.displayTitle + self.items = items + self.accentColor = section.category == .overdue ? .danger : .primary + } + + init(todayItems: [TodayTodoItem]) { + self.id = .today + self.category = nil + self.title = String(localized: "today_section_today", bundle: PresentationResources.bundle) + self.items = todayItems + self.accentColor = .primary + } +} + +private struct TodayTodoCard: View { + @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(30) + let item: TodayTodoItem + let onComplete: () -> Void + let onTogglePinned: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + completionStatus + + NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + todoInformation + Text(item.title) + .font(.headline) + .strikethrough(item.isCompleted) + .foregroundStyle(item.isCompleted ? Color.textSecondary : .primary) + .multilineTextAlignment(.leading) + if !item.content.isEmpty { + Text(item.content) + .font(.callout) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.callout.bold()) + .foregroundStyle(Color.textTertiary) + } + } + .buttonStyle(.plain) + .todoDetailPreview(todoId: item.id) + + Button(action: onTogglePinned) { + Image(systemName: item.isPinned ? "star.fill" : "star") + .font(.title3) + .foregroundStyle(item.isPinned ? Color.warning : Color.textTertiary) + } + .buttonStyle(.plain) + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + } + + @ViewBuilder + private var completionStatus: some View { + if item.isCompleted { + Image(systemName: "checkmark.circle.fill") + .resizable() + .scaledToFit() + .foregroundStyle(Color.success) + .frame(width: completionSize, height: completionSize) + } else { + Button(action: onComplete) { + Image(systemName: "circle") + .resizable() + .scaledToFit() + .foregroundStyle(Color.textTertiary) + .frame(width: completionSize, height: completionSize) + } + .buttonStyle(.plain) + } + } + + private var todoInformation: some View { + let category = TodoCategoryItem(from: item.category) + return HStack(spacing: 8) { + Label(category.localizedName, systemImage: category.symbolName) + .font(.caption.weight(.semibold)) + .foregroundStyle(category.color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(category.color.opacity(0.1), in: .rect(cornerRadius: 8)) + + if let dueDate = item.dueDate { + Label(dueDateText(dueDate), systemImage: "clock") + .font(.caption.weight(.semibold)) + .foregroundStyle(dueDateColor(dueDate)) + } + } + } + + private func dueDateText(_ date: Date) -> String { + let calendar = Calendar.autoupdatingCurrent + if calendar.isDateInToday(date) { + return date.formatted(date: .omitted, time: .shortened) + } + if calendar.isDateInYesterday(date) { + return String(localized: "today_due_yesterday", bundle: PresentationResources.bundle) + } + return date.formatted(.dateTime.month(.abbreviated).day().weekday(.abbreviated).hour().minute()) + } + + private func dueDateColor(_ date: Date) -> Color { + let calendar = Calendar.autoupdatingCurrent + if calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) { + return .danger + } + return .accent + } +} + private extension TodayDisplayOptions.DueDateVisibility { var title: String { switch self { @@ -289,120 +527,32 @@ private extension TodayFeature.SectionScope { return String(localized: "today_summary_due_soon", bundle: PresentationResources.bundle) } } +} - var accentColor: Color { +private extension TodayFeature.SectionCategory { + var displayTitle: String { switch self { - case .all: - return .blue - case .focused: - return .orange case .overdue: - return .red + return String(localized: "today_section_overdue", bundle: PresentationResources.bundle) case .dueSoon: - return .green - } - } -} - -private struct SummaryCard: View { - let title: String - let value: Int - let accentColor: Color - let isSelected: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.caption) - .foregroundStyle(isSelected ? accentColor : .secondary) - Text("\(value)") - .font(.title2.bold()) - .foregroundStyle(Color(.label)) + return String(localized: "today_section_upcoming", bundle: PresentationResources.bundle) + case .focused: + return String(localized: "today_section_focused", bundle: PresentationResources.bundle) + case .later: + return String(localized: "today_section_later", bundle: PresentationResources.bundle) + case .unscheduled: + return String(localized: "today_section_unscheduled", bundle: PresentationResources.bundle) } - .frame(width: 96, alignment: .leading) - .padding(14) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(isSelected ? accentColor.opacity(0.2) : accentColor.opacity(0.12)) - .strokeBorder( - isSelected ? accentColor.opacity(0.55) : accentColor.opacity(0.18), - lineWidth: isSelected ? 1.5 : 1 - ) - ) - .scaleEffect(isSelected ? 1 : 0.98) } } -private struct TodayTodoRow: View { - private let calendar = Calendar.current - let item: TodayTodoItem - - var body: some View { - let todoCategoryItem = TodoCategoryItem(from: item.category) - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: todoCategoryItem.symbolName) - .foregroundStyle(todoCategoryItem.color) - .frame(width: 18) - Text(item.title) - .font(.headline) - .foregroundStyle(Color(.label)) - .lineLimit(1) - Text("#\(item.number)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.gray) - .fixedSize(horizontal: true, vertical: false) - Spacer() - } - - HStack(spacing: 8) { - Text(todoCategoryItem.localizedName) - .font(.caption.weight(.semibold)) - .foregroundStyle(todoCategoryItem.color) - - if let dueDate { - Text(dueDate.text) - .font(.caption2.weight(.semibold)) - .foregroundStyle(dueDate.textColor) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(dueDate.backgroundColor) - ) - } - } - - if !item.tags.isEmpty { - TagList(item.tags, lineLimit: 1) - } - } - } - - private var dueDate: DueDateBadge? { - guard let date = item.dueDate else { return nil } - let today = calendar.startOfDay(for: Date()) - let dueDay = calendar.startOfDay(for: date) - - if dueDay < today { - return DueDateBadge( - text: String(localized: "today_due_overdue", bundle: PresentationResources.bundle), - textColor: .red, - backgroundColor: Color.red.opacity(0.12) - ) +private extension TodayFeature.TodayAchievement.Status { + var title: String { + let key = switch self { + case .empty: "today_achievement_status_empty" + case .inProgress: "today_achievement_status_in_progress" + case .completed: "today_achievement_status_completed" } - - let formatted = date.formatted(date: .abbreviated, time: .omitted) - return DueDateBadge( - text: formatted, - textColor: .blue, - backgroundColor: Color.blue.opacity(0.12) - ) - } - - private struct DueDateBadge { - let text: String - let textColor: Color - let backgroundColor: Color + return String(localized: String.LocalizationValue(key), bundle: PresentationResources.bundle) } } From 21607b77a0be4c6ed8b240354c2de3408ba586da Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 01:48:19 +0900 Subject: [PATCH 05/20] =?UTF-8?q?ui:=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=83=89=20=EA=B5=AC=ED=98=84=20=EB=9D=BC=EB=B2=A8=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TodayTab/Sources/TodayView.swift | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 753eae12..305018de 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -392,10 +392,12 @@ private struct TodayDisplaySection: Identifiable { } private struct TodayTodoCard: View { + @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(30) let item: TodayTodoItem let onComplete: () -> Void let onTogglePinned: () -> Void + private var isDarkMode: Bool { colorScheme == .dark } var body: some View { HStack(alignment: .top, spacing: 12) { @@ -428,12 +430,10 @@ private struct TodayTodoCard: View { .buttonStyle(.plain) .todoDetailPreview(todoId: item.id) - Button(action: onTogglePinned) { - Image(systemName: item.isPinned ? "star.fill" : "star") - .font(.title3) - .foregroundStyle(item.isPinned ? Color.warning : Color.textTertiary) - } - .buttonStyle(.plain) + Image(systemName: item.isPinned ? "star.fill" : "star") + .font(.title3) + .foregroundStyle(item.isPinned ? Color.warning : .textTertiary) + .onTapGesture { onTogglePinned() } } .padding(20) .background { @@ -463,20 +463,30 @@ private struct TodayTodoCard: View { } } + @ViewBuilder private var todoInformation: some View { let category = TodoCategoryItem(from: item.category) - return HStack(spacing: 8) { - Label(category.localizedName, systemImage: category.symbolName) - .font(.caption.weight(.semibold)) - .foregroundStyle(category.color) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(category.color.opacity(0.1), in: .rect(cornerRadius: 8)) + HStack(spacing: 8) { + HStack(spacing: 4) { + Image(systemName: category.symbolName) + Text(category.localizedName) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(isDarkMode ? .white : category.color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + category.color.opacity(isDarkMode ? 1 : 0.2), + in: .rect(cornerRadius: 8) + ) if let dueDate = item.dueDate { - Label(dueDateText(dueDate), systemImage: "clock") - .font(.caption.weight(.semibold)) - .foregroundStyle(dueDateColor(dueDate)) + HStack(spacing: 4) { + Image(systemName: "clock") + Text(dueDateText(dueDate)) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(dueDateColor(dueDate)) } } } From 386a2d1ba103ceddea9f3954a12e93b19ed8fedf Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 01:50:28 +0900 Subject: [PATCH 06/20] =?UTF-8?q?ui:=20chevron=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/TodayTab/Sources/TodayView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 305018de..110d7ffd 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -422,6 +422,11 @@ private struct TodayTodoCard: View { } .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: item.isPinned ? "star.fill" : "star") + .font(.title3) + .foregroundStyle(item.isPinned ? Color.warning : .textTertiary) + .onTapGesture { onTogglePinned() } + Image(systemName: "chevron.right") .font(.callout.bold()) .foregroundStyle(Color.textTertiary) @@ -429,11 +434,6 @@ private struct TodayTodoCard: View { } .buttonStyle(.plain) .todoDetailPreview(todoId: item.id) - - Image(systemName: item.isPinned ? "star.fill" : "star") - .font(.title3) - .foregroundStyle(item.isPinned ? Color.warning : .textTertiary) - .onTapGesture { onTogglePinned() } } .padding(20) .background { From 957166063b81b5fa8b71ebf07cb5c52b7f299148 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 09:32:58 +0900 Subject: [PATCH 07/20] =?UTF-8?q?ui:=20=EC=A4=91=EC=9A=94=20=ED=86=A0?= =?UTF-8?q?=EA=B8=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 --- .../TodayTab/Sources/TodayFeature.swift | 25 -------------- .../TodayTab/Sources/TodayView.swift | 9 +---- .../Tests/TodayFeatureSupportTests.swift | 25 -------------- .../Tests/TodayFeatureTestAssertions.swift | 33 ------------------- .../Tests/TodayFeatureTestDoubles.swift | 6 ---- 5 files changed, 1 insertion(+), 97 deletions(-) diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature.swift b/Application/Presentation/TodayTab/Sources/TodayFeature.swift index 24085543..ef7d806a 100644 --- a/Application/Presentation/TodayTab/Sources/TodayFeature.swift +++ b/Application/Presentation/TodayTab/Sources/TodayFeature.swift @@ -51,7 +51,6 @@ struct TodayFeature { case setSectionScope(SectionScope) case resetDisplayOptions case completeTodo(TodayTodoItem) - case togglePinned(TodayTodoItem) case store(StoreAction) case loading(LoadingFeature.Action) @@ -121,8 +120,6 @@ struct TodayFeature { return updateDisplayOptionsEffect(state.displayOptions) case .completeTodo(let item): return completeTodoEffect(item) - case .togglePinned(let item): - return togglePinnedEffect(item) case .store(.setAlert): state.alert = Self.alertState() case .store(.setTodos(let incomplete, let completedToday, let interval)): @@ -323,28 +320,6 @@ private extension TodayFeature { } } - func togglePinnedEffect(_ item: TodayTodoItem) -> Effect { - .run { [fetchTodoByIdUseCase, upsertTodoUseCase, now] send in - await send(.loading(.begin(target: .default, mode: .delayed))) - do { - var todo = try await fetchTodoByIdUseCase.execute(item.id) - todo.isPinned.toggle() - todo.updatedAt = now - try await upsertTodoUseCase.execute(todo) - guard let todayTodoItem = TodayTodoItem(from: todo) else { - await send(.loading(.end(target: .default, mode: .delayed))) - await send(.store(.setAlert)) - return - } - await send(.store(.updateTodo(todayTodoItem))) - await send(.loading(.end(target: .default, mode: .delayed))) - } catch { - await send(.loading(.end(target: .default, mode: .delayed))) - await send(.store(.setAlert)) - } - } - } - static func alertState() -> AlertState { AlertState { TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 110d7ffd..aa9bb34c 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -260,8 +260,7 @@ public struct TodayView: View { ForEach(section.items) { item in TodayTodoCard( item: item, - onComplete: { store.send(.completeTodo(item)) }, - onTogglePinned: { store.send(.togglePinned(item)) } + onComplete: { store.send(.completeTodo(item)) } ) } } @@ -396,7 +395,6 @@ private struct TodayTodoCard: View { @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(30) let item: TodayTodoItem let onComplete: () -> Void - let onTogglePinned: () -> Void private var isDarkMode: Bool { colorScheme == .dark } var body: some View { @@ -422,11 +420,6 @@ private struct TodayTodoCard: View { } .frame(maxWidth: .infinity, alignment: .leading) - Image(systemName: item.isPinned ? "star.fill" : "star") - .font(.title3) - .foregroundStyle(item.isPinned ? Color.warning : .textTertiary) - .onTapGesture { onTogglePinned() } - Image(systemName: "chevron.right") .font(.callout.bold()) .foregroundStyle(Color.textTertiary) diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift index 98909cbd..c82c93c8 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift @@ -139,31 +139,6 @@ struct TodayFeatureSupportTests { ) } - @Test("TodayFeature togglePinned는 Todo를 갱신하고 섹션을 다시 계산한다") - func TodayFeature_togglePinned는_Todo를_갱신하고_섹션을_다시_계산한다() async throws { - let todos = makeTodaySectionTodos() - let fetchSpy = TodayFetchTodosUseCaseSpy( - pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) - ] - ) - let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) - let upsertSpy = TodayUpsertTodoUseCaseSpy() - let adapter = TodayStoreTestAdapter( - fetchUseCase: fetchSpy, - fetchTodoByIdUseCase: fetchByIdSpy, - upsertUseCase: upsertSpy - ) - - try await verifyTodayTogglePinned( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - fetchTodoByIdUseCaseSpy: fetchByIdSpy, - upsertTodoUseCaseSpy: upsertSpy - ) - } - @Test("TodayFeature completeTodo는 Todo를 제거하고 완료 이벤트를 남긴다") func TodayFeature_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { let todos = makeTodaySectionTodos() diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift index 850f5e2e..fb174961 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift @@ -148,39 +148,6 @@ func verifyTodayDisplayOptions( ]) } -@MainActor -func verifyTodayTogglePinned( - adapter: Adapter, - fetchUseCaseSpy: TodayFetchTodosUseCaseSpy, - fetchTodoByIdUseCaseSpy: TodayFetchTodoByIdUseCaseSpy, - upsertTodoUseCaseSpy: TodayUpsertTodoUseCaseSpy -) async throws { - try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) - - let item = try #require(adapter.todos.first { $0.id == "later" }) - await adapter.togglePinned(item) - - await waitUntilTodayMainActor { - adapter.todos.first { $0.id == "later" }?.isPinned == true - } - - #expect(fetchTodoByIdUseCaseSpy.todoIds == ["later"]) - #expect(upsertTodoUseCaseSpy.todos.last?.id == "later") - #expect(upsertTodoUseCaseSpy.todos.last?.isPinned == true) - #expect(adapter.summaryCounts == [ - .all: 5, - .focused: 2, - .overdue: 1, - .dueSoon: 2 - ]) - #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused", "later"]), - TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), - TodayDisplayedSection(category: .dueSoon, itemIds: ["due-soon"]), - TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) - ]) -} - @MainActor func verifyTodayCompleteTodo( adapter: Adapter, diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift index 6f3febce..7a9374ef 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift @@ -57,7 +57,6 @@ protocol TodayStateDriving { func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async func resetDisplayOptions() async func completeTodo(_ item: TodayTodoItem) async - func togglePinned(_ item: TodayTodoItem) async } @MainActor @@ -170,11 +169,6 @@ struct TodayStoreTestAdapter: TodayStateDriving { await drainReceivedActions() } - func togglePinned(_ item: TodayTodoItem) async { - await store.send(.togglePinned(item)) - await drainReceivedActions() - } - private func drainReceivedActions() async { for _ in 0..<10 { await store.skipReceivedActions(strict: false) From 68db5a713914784fab55299bb6bd542f2d4fa428 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 13:09:02 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20Today=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 9 +- .../Resources/Localizable.xcstrings | 269 +---------- .../Sources/TodayDependencyPreparation.swift | 19 +- .../TodayTab/Sources/TodayFeature+State.swift | 263 +++++------ .../TodayTab/Sources/TodayFeature.swift | 89 ++-- .../TodayTab/Sources/TodayTodoItem.swift | 2 - .../TodayTab/Sources/TodayView.swift | 416 +++++++++--------- .../Tests/TodayFeatureSupportTests.swift | 88 +++- .../Tests/TodayFeatureTestAssertions.swift | 84 +--- .../Tests/TodayFeatureTestDoubles.swift | 113 ++--- .../Tests/TodayFeatureTestSpies.swift | 25 +- .../TodayTab/Tests/TodayFeatureTests.swift | 100 ++--- 12 files changed, 535 insertions(+), 942 deletions(-) diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 8b3d7e7f..33545269 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -107,13 +107,10 @@ private extension AppGraph { func prepareTodayDependencies(_ dependencies: inout DependencyValues) { TodayPresentationDependencyPreparation.prepare( &dependencies, - fetchDisplayOptionsUseCase: userPreferencesGraphSet - .userPreferencesUseCaseGraph - .fetchTodayDisplayOptionsUseCase, fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, - updateDisplayOptionsUseCase: userPreferencesGraphSet - .userPreferencesUseCaseGraph - .updateTodayDisplayOptionsUseCase + fetchCategoryPreferencesUseCase: todoGraphSet + .todoCategoryUseCaseGraph + .fetchTodoCategoryPreferencesUseCase ) } diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index a3b81ec2..c8536dbf 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -2168,74 +2168,6 @@ } } }, - "today_due_visibility_all" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "전체" - } - } - } - }, - "today_due_visibility_label" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "View Range" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "보기 범위" - } - } - } - }, - "today_due_visibility_with_due" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "With Due Date Only" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "기한 있는 Todo만" - } - } - } - }, - "today_due_visibility_without_due" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Without Due Date Only" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "기한 없는 Todo만" - } - } - } - }, "today_empty_all_message" : { "extractionState" : "manual", "localizations" : { @@ -2270,53 +2202,19 @@ } } }, - "today_empty_due_soon_message" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Todos due soon will appear here first." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있어요." - } - } - } - }, - "today_empty_due_soon_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There is no schedule within 7 days." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "7일 내 일정이 없어요." - } - } - } - }, "today_empty_filtered_message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Adjust the view range from the toolbar or return to the full view." + "value" : "Adjust the filters or return to Remaining." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "툴바에서 보기 범위를 조정하거나 전체 보기로 돌아가세요." + "value" : "필터를 조정하거나 남은 일 보기로 돌아가세요." } } } @@ -2338,104 +2236,19 @@ } } }, - "today_empty_focused_message" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pinned todos will appear here right away." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있어요." - } - } - } - }, - "today_empty_focused_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There is nothing to focus on." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "중요 Todo가 없어요." - } - } - } - }, - "today_empty_overdue_message" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There are no overdue todos right now." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "지금은 기한이 지난 Todo가 없어요." - } - } - } - }, - "today_empty_overdue_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There are no overdue todos." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "지난 마감 Todo가 없어요." - } - } - } - }, - "today_pinned_only" : { + "today_filter_all_categories" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Pinned Only" + "value" : "All Categories" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "중요 표시만" - } - } - } - }, - "today_pinned_only_description" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Only pinned todos are shown." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "중요 표시한 Todo만 표시돼요." + "value" : "모든 카테고리" } } } @@ -2457,40 +2270,6 @@ } } }, - "today_section_due_soon_format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Due Within %1$lld Days" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$lld일 내 일정" - } - } - } - }, - "today_section_focused" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Focus" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "중요 Todo" - } - } - } - }, "today_section_later" : { "extractionState" : "manual", "localizations" : { @@ -2593,7 +2372,7 @@ } } }, - "today_summary_all" : { + "today_filter_remaining" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -2610,24 +2389,7 @@ } } }, - "today_summary_due_soon" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Within 7 Days" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "7일 내" - } - } - } - }, - "today_summary_focused" : { + "today_filter_important" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -2644,23 +2406,6 @@ } } }, - "today_summary_overdue" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Overdue" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "지연" - } - } - } - }, "TODO" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift b/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift index cc76e25c..9023a327 100644 --- a/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift +++ b/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift @@ -11,25 +11,10 @@ import PresentationShared public enum TodayDependencyPreparation { public static func prepare( _ dependencies: inout DependencyValues, - fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase, fetchTodosUseCase: FetchTodosUseCase, - updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase + fetchCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase ) { - dependencies.todayFetchDisplayOptionsUseCase = fetchDisplayOptionsUseCase dependencies.todayFetchTodosUseCase = fetchTodosUseCase - dependencies.updateTodayDisplayOptionsUseCase = updateDisplayOptionsUseCase - } -} - -extension DependencyValues { - var todayFetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase { - get { self[TodayFetchDisplayOptionsUseCaseKey.self] } - set { self[TodayFetchDisplayOptionsUseCaseKey.self] = newValue } - } -} - -private enum TodayFetchDisplayOptionsUseCaseKey: DependencyKey { - static var liveValue: FetchTodayDisplayOptionsUseCase { - preconditionFailure("FetchTodayDisplayOptionsUseCase must be provided.") + dependencies.fetchTodoCategoryPreferencesUseCase = fetchCategoryPreferencesUseCase } } diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift index a9bb0b3d..a171572c 100644 --- a/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift +++ b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift @@ -5,7 +5,6 @@ // Created by opfic on 6/14/26. // -import Core import Foundation import PresentationShared @@ -34,20 +33,18 @@ extension TodayFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? - var todos: [TodayTodoItem] = [] - var completedTodayTodos: [TodayTodoItem] = [] - var selectedSectionScope: SectionScope = .all - var displayOptions: TodayDisplayOptions + var todos = [TodayTodoItem]() + var completedTodayTodos = [TodayTodoItem]() + var categories = [TodoCategoryItem]() + var selectedTodoScope = TodoScope.remaining + var selectedCategoryID: String? + var isCategoryFilterPresented = false var loading = LoadingFeature.State() var todayInterval: DateInterval var isIncompleteDataLoaded = false var isCompletedTodayDataLoaded = false - init( - displayOptions: TodayDisplayOptions = .default, - now: Date = Date() - ) { - self.displayOptions = displayOptions + init(now: Date = Date()) { self.todayInterval = TodayFeature.dayInterval(containing: now) } @@ -59,6 +56,18 @@ extension TodayFeature { isIncompleteDataLoaded && isCompletedTodayDataLoaded } + var visibleCategories: [TodoCategoryItem] { + categories.filter(\.isVisible) + } + + var selectedCategory: TodoCategoryItem? { + visibleCategories.first { $0.id == selectedCategoryID } + } + + var hasActiveFilters: Bool { + selectedTodoScope == .important || selectedCategoryID != nil + } + var todayTodos: [TodayTodoItem] { guard isTodayDataLoaded else { return [] } return TodayFeature.todayTodos( @@ -77,86 +86,67 @@ extension TodayFeature { ) } + var filteredTodos: [TodayTodoItem] { + TodayFeature.filteredTodos( + todos, + scope: selectedTodoScope, + categoryID: selectedCategoryID + ) + } + + var filteredCompletedTodayTodos: [TodayTodoItem] { + TodayFeature.filteredTodos( + completedTodayTodos, + scope: selectedTodoScope, + categoryID: selectedCategoryID + ) + } + var sections: [SectionContent] { - let now = Date() - let items = TodayFeature.groupedSectionItems( - from: TodayFeature.displayedTodos( - todos: todos, - displayOptions: displayOptions - ), - now: now + var collection = TodayFeature.groupedSectionItems( + from: filteredTodos, + interval: todayInterval ) + collection.today.append(contentsOf: filteredCompletedTodayTodos) - switch selectedSectionScope { - case .all: - return - TodayFeature.makeSection( - category: .focused, - title: String(localized: "today_section_focused", bundle: PresentationResources.bundle), - items: items.focused - ) - + TodayFeature.makeSection( - category: .overdue, - title: String(localized: "today_section_overdue", bundle: PresentationResources.bundle), - items: items.overdue - ) - + TodayFeature.makeSection( - category: .dueSoon, - title: String.localizedStringWithFormat( - String(localized: "today_section_due_soon_format", bundle: PresentationResources.bundle), - Int64(TodayFeature.upcomingWindowDays) - ), - items: items.dueSoon - ) - + TodayFeature.makeSection( - category: .later, - title: String(localized: "today_section_later", bundle: PresentationResources.bundle), - items: items.later - ) - + TodayFeature.makeSection( - category: .unscheduled, - title: String(localized: "today_section_unscheduled", bundle: PresentationResources.bundle), - items: items.unscheduled - ) - case .focused: - return TodayFeature.makeSection( - category: .focused, - title: String(localized: "today_section_focused", bundle: PresentationResources.bundle), - items: items.focused - ) - case .overdue: - return TodayFeature.makeSection( + return + TodayFeature.makeSection( category: .overdue, title: String(localized: "today_section_overdue", bundle: PresentationResources.bundle), - items: items.overdue + items: collection.overdue ) - case .dueSoon: - return TodayFeature.makeSection( - category: .dueSoon, - title: String.localizedStringWithFormat( - String(localized: "today_section_due_soon_format", bundle: PresentationResources.bundle), - Int64(TodayFeature.upcomingWindowDays) - ), - items: items.dueSoon + + TodayFeature.makeSection( + category: .today, + title: String(localized: "today_section_today", bundle: PresentationResources.bundle), + items: collection.today + ) + + TodayFeature.makeSection( + category: .upcoming, + title: String(localized: "today_section_upcoming", bundle: PresentationResources.bundle), + items: collection.upcoming + ) + + TodayFeature.makeSection( + category: .later, + title: String(localized: "today_section_later", bundle: PresentationResources.bundle), + items: collection.later + ) + + TodayFeature.makeSection( + category: .unscheduled, + title: String(localized: "today_section_unscheduled", bundle: PresentationResources.bundle), + items: collection.unscheduled ) - } } - var summaryCounts: [SectionScope: Int] { - let now = Date() - return Dictionary( - uniqueKeysWithValues: SectionScope.allCases.map { scope in - ( - scope, - TodayFeature.summaryValue( - for: scope, - todos: todos, - displayOptions: displayOptions, - now: now - ) - ) - } + var summaryCounts: [TodoScope: Int] { + let todos = TodayFeature.filteredTodos( + todos, + scope: .remaining, + categoryID: selectedCategoryID ) + return [ + .remaining: todos.count, + .important: todos.filter(\.isPinned).count + ] } } @@ -197,115 +187,58 @@ extension TodayFeature { ) } - static func summaryValue( - for scope: SectionScope, - todos: [TodayTodoItem], - displayOptions: TodayDisplayOptions, - now: Date - ) -> Int { - let displayedTodos = displayedTodos( - todos: todos, - displayOptions: displayOptions - ) - - switch scope { - case .all: - return displayedTodos.count - case .focused: - return displayedTodos.filter(\.isPinned).count - case .overdue: - return displayedTodos.filter { isOverdue($0, now: now) }.count - case .dueSoon: - return displayedTodos.filter { isDueSoon($0, now: now) }.count - } - } - - static func displayedTodos( - todos: [TodayTodoItem], - displayOptions: TodayDisplayOptions + static func filteredTodos( + _ todos: [TodayTodoItem], + scope: TodoScope, + categoryID: String? ) -> [TodayTodoItem] { - let dueDateFilteredTodos: [TodayTodoItem] - switch displayOptions.dueDateVisibility { - case .all: - dueDateFilteredTodos = todos - case .withDueDateOnly: - dueDateFilteredTodos = todos.filter { $0.dueDate != nil } - case .withoutDueDateOnly: - dueDateFilteredTodos = todos.filter { $0.dueDate == nil } - } - - switch displayOptions.focusVisibility { - case .all: - return dueDateFilteredTodos - case .focusedOnly: - return dueDateFilteredTodos.filter(\.isPinned) + todos.filter { item in + if scope == .important, !item.isPinned { return false } + if let categoryID, item.category.storageValue != categoryID { return false } + return true } } static func groupedSectionItems( from items: [TodayTodoItem], - now: Date - ) -> TodayFeature.SectionCollection { - let calendar = Calendar.current - let startOfToday = calendar.startOfDay(for: now) - guard let windowEnd = calendar.date( + interval: DateInterval, + calendar: Calendar = .autoupdatingCurrent + ) -> SectionCollection { + guard let upcomingEnd = calendar.date( byAdding: .day, - value: TodayFeature.upcomingWindowDays, - to: startOfToday + value: upcomingWindowDays, + to: interval.end ) else { - return TodayFeature.SectionCollection( - focused: items.filter(\.isPinned), - unscheduled: items.filter { !$0.isPinned && $0.dueDate == nil } - ) + return SectionCollection(unscheduled: pinnedFirst(items.filter { $0.dueDate == nil })) } - var collection = TodayFeature.SectionCollection() + var collection = SectionCollection() for item in items { - if item.isPinned { - collection.focused.append(item) - continue - } - guard let dueDate = item.dueDate else { collection.unscheduled.append(item) continue } - let dueDay = calendar.startOfDay(for: dueDate) - if dueDay < startOfToday { + if dueDate < interval.start { collection.overdue.append(item) - } else if dueDay <= windowEnd { - collection.dueSoon.append(item) + } else if dueDate < interval.end { + collection.today.append(item) + } else if dueDate < upcomingEnd { + collection.upcoming.append(item) } else { collection.later.append(item) } } + collection.overdue = pinnedFirst(collection.overdue) + collection.today = pinnedFirst(collection.today) + collection.upcoming = pinnedFirst(collection.upcoming) + collection.later = pinnedFirst(collection.later) + collection.unscheduled = pinnedFirst(collection.unscheduled) return collection } - static func isOverdue(_ item: TodayTodoItem, now: Date) -> Bool { - guard let dueDate = item.dueDate else { return false } - let calendar = Calendar.current - return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: now) - } - - static func isDueSoon(_ item: TodayTodoItem, now: Date) -> Bool { - guard let dueDate = item.dueDate else { return false } - let calendar = Calendar.current - let startOfToday = calendar.startOfDay(for: now) - guard let windowEnd = calendar.date( - byAdding: .day, - value: TodayFeature.upcomingWindowDays, - to: startOfToday - ) else { - return false - } - let dueDay = calendar.startOfDay(for: dueDate) - return startOfToday <= dueDay && dueDay <= windowEnd - } - static func makeSection( category: SectionCategory, title: String, @@ -314,4 +247,8 @@ extension TodayFeature { guard !items.isEmpty else { return [] } return [SectionContent(category: category, title: title, items: items)] } + + private static func pinnedFirst(_ items: [TodayTodoItem]) -> [TodayTodoItem] { + items.filter(\.isPinned) + items.filter { !$0.isPinned } + } } diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature.swift b/Application/Presentation/TodayTab/Sources/TodayFeature.swift index ef7d806a..ffdc33b8 100644 --- a/Application/Presentation/TodayTab/Sources/TodayFeature.swift +++ b/Application/Presentation/TodayTab/Sources/TodayFeature.swift @@ -12,19 +12,17 @@ import PresentationShared @Reducer struct TodayFeature { - enum SectionScope: Hashable, CaseIterable { - case all - case focused - case overdue - case dueSoon + enum TodoScope: Hashable, CaseIterable { + case remaining + case important } enum SectionCategory: Hashable { case later case unscheduled - case focused + case today case overdue - case dueSoon + case upcoming } struct SectionContent: Identifiable, Equatable { @@ -35,9 +33,9 @@ struct TodayFeature { } struct SectionCollection { - var focused: [TodayTodoItem] = [] var overdue: [TodayTodoItem] = [] - var dueSoon: [TodayTodoItem] = [] + var today: [TodayTodoItem] = [] + var upcoming: [TodayTodoItem] = [] var later: [TodayTodoItem] = [] var unscheduled: [TodayTodoItem] = [] } @@ -48,8 +46,8 @@ struct TodayFeature { case refresh case fetchData case checkCurrentDate(Date) - case setSectionScope(SectionScope) - case resetDisplayOptions + case setTodoScope(TodoScope) + case setCategory(String?) case completeTodo(TodayTodoItem) case store(StoreAction) case loading(LoadingFeature.Action) @@ -62,15 +60,16 @@ struct TodayFeature { interval: DateInterval ) case setCompletedTodayTodos([TodayTodoItem], interval: DateInterval) + case setCategories([TodoCategoryItem]) case updateTodo(TodayTodoItem) case removeTodo(String) } } @Dependency(\.todayFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchCategoryPreferencesUseCase @Dependency(\.fetchTodoByIdUseCase) var fetchTodoByIdUseCase @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase - @Dependency(\.updateTodayDisplayOptionsUseCase) var updateTodayDisplayOptionsUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase @Dependency(\.date.now) var now @@ -86,18 +85,20 @@ struct TodayFeature { switch action { case .alert: break - case .binding(\.displayOptions.dueDateVisibility), - .binding(\.displayOptions.focusVisibility), - .binding(\.displayOptions.isFocusedOnly): - return updateDisplayOptionsEffect(state.displayOptions) case .binding: break case .refresh: let interval = prepareTodayInterval(state: &state, now: now) - return fetchTodosEffect(interval: interval, showsIndicator: false) + return .concatenate( + fetchTodosEffect(interval: interval, showsIndicator: false), + fetchCategoriesEffect() + ) case .fetchData: let interval = prepareTodayInterval(state: &state, now: now) - return fetchTodosEffect(interval: interval) + return .concatenate( + fetchTodosEffect(interval: interval), + fetchCategoriesEffect() + ) case .checkCurrentDate(let date): let interval = Self.dayInterval(containing: date) guard state.todayInterval != interval else { break } @@ -109,15 +110,10 @@ struct TodayFeature { return fetchTodosEffect(interval: interval, showsIndicator: false) } return fetchCompletedTodayTodosEffect(interval: interval) - case .setSectionScope(let scope): - if state.selectedSectionScope == scope, scope != .all { - state.selectedSectionScope = .all - } else { - state.selectedSectionScope = scope - } - case .resetDisplayOptions: - state.displayOptions = .default - return updateDisplayOptionsEffect(state.displayOptions) + case .setTodoScope(let scope): + state.selectedTodoScope = scope + case .setCategory(let categoryID): + state.selectedCategoryID = categoryID case .completeTodo(let item): return completeTodoEffect(item) case .store(.setAlert): @@ -132,6 +128,13 @@ struct TodayFeature { guard state.todayInterval == interval else { break } state.completedTodayTodos = todos state.isCompletedTodayDataLoaded = true + case .store(.setCategories(let categories)): + state.categories = categories + let visibleCategoryIDs = Set(state.visibleCategories.map(\.id)) + if let categoryID = state.selectedCategoryID, + !visibleCategoryIDs.contains(categoryID) { + state.selectedCategoryID = nil + } case .store(.updateTodo(let item)): if item.isCompleted { state.todos.removeAll { $0.id == item.id } @@ -170,11 +173,6 @@ extension DependencyValues { get { self[TodayFetchTodosUseCaseKey.self] } set { self[TodayFetchTodosUseCaseKey.self] = newValue } } - - var updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase { - get { self[UpdateTodayDisplayOptionsUseCaseKey.self] } - set { self[UpdateTodayDisplayOptionsUseCaseKey.self] = newValue } - } } private enum TodayFetchTodosUseCaseKey: DependencyKey { @@ -187,16 +185,6 @@ private enum TodayFetchTodosUseCaseKey: DependencyKey { } } -private enum UpdateTodayDisplayOptionsUseCaseKey: DependencyKey { - static var liveValue: UpdateTodayDisplayOptionsUseCase { - preconditionFailure("UpdateTodayDisplayOptionsUseCase must be provided.") - } - - static var testValue: UpdateTodayDisplayOptionsUseCase { - liveValue - } -} - private extension TodayFeature { func prepareTodayInterval(state: inout State, now: Date) -> DateInterval { let interval = Self.dayInterval(containing: now) @@ -277,6 +265,17 @@ private extension TodayFeature { } } + func fetchCategoriesEffect() -> Effect { + .run { [fetchCategoryPreferencesUseCase] send in + do { + let preferences = try await fetchCategoryPreferencesUseCase.execute() + await send(.store(.setCategories(preferences.map(TodoCategoryItem.init(from:))))) + } catch { + await send(.store(.setAlert)) + } + } + } + static func completedTodayQuery(interval: DateInterval) -> TodoQuery { TodoQuery( completionFilter: .completed, @@ -290,12 +289,6 @@ private extension TodayFeature { ) } - func updateDisplayOptionsEffect(_ options: TodayDisplayOptions) -> Effect { - .run { [updateTodayDisplayOptionsUseCase] _ in - updateTodayDisplayOptionsUseCase.execute(options) - } - } - func completeTodoEffect(_ item: TodayTodoItem) -> Effect { .run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase, now] send in await send(.loading(.begin(target: .default, mode: .delayed))) diff --git a/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift index 073f5954..d9d100e5 100644 --- a/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift +++ b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift @@ -13,7 +13,6 @@ public struct TodayTodoItem: Identifiable, Hashable { public let number: Int public let title: String public let content: String - public let tags: [String] public let isPinned: Bool public let isCompleted: Bool public let updatedAt: Date @@ -25,7 +24,6 @@ public struct TodayTodoItem: Identifiable, Hashable { self.number = todo.number self.title = todo.title self.content = todo.content - self.tags = todo.tags self.isPinned = todo.isPinned self.isCompleted = todo.isCompleted self.updatedAt = todo.updatedAt diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index aa9bb34c..00bc6a40 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -21,11 +21,8 @@ public struct TodayView: View { isSelected: Bool, windowEvent: TodoEditorWindowEvent ) { - @Dependency(\.todayFetchDisplayOptionsUseCase) var fetchDisplayOptionsUseCase self._store = State(initialValue: Store( - initialState: TodayFeature.State( - displayOptions: fetchDisplayOptionsUseCase.execute() - ) + initialState: TodayFeature.State() ) { TodayFeature() }) @@ -38,26 +35,35 @@ public struct TodayView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 24, pinnedViews: [.sectionHeaders]) { Section { - scopePicker - if displaySections.isEmpty, !store.isLoading { + if store.sections.isEmpty, !store.isLoading { emptyContent - .padding(.horizontal) } else { - ForEach(displaySections) { section in - todoSection(section) - .padding(.horizontal) + ForEach(store.sections) { section in + TodoSection( + section: section, + onComplete: { store.send(.completeTodo($0)) } + ) } } } header: { - achievementCard + VStack { + achievementCard + filterBar + } + .padding(.bottom, 6) + .background(Color.appBackground) } } + .padding(.horizontal) .padding(.bottom, 24) } .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground.ignoresSafeArea()) .refreshable { await store.send(.refresh).finish() } .navigationDestination(for: TodayRoute.self, destination: destination) + .sheet(isPresented: $store.isCategoryFilterPresented) { + CategoryFilterSheet(store: store) + } } .onChange(of: isSelected, initial: true) { _, isSelected in if isSelected { @@ -110,7 +116,6 @@ public struct TodayView: View { Spacer(minLength: 8) progressCount - filterMenu } ProgressView(value: store.todayAchievement?.progress ?? 0) @@ -122,149 +127,74 @@ public struct TodayView: View { .fill(Color.surface) .strokeBorder(Color.border, lineWidth: 2) } - .padding(.horizontal) - .background(Color.appBackground) } + @ViewBuilder private var progressCount: some View { - Group { - if let achievement = store.todayAchievement { - HStack(alignment: .firstTextBaseline, spacing: 5) { - Text("\(achievement.completedCount)") - .font(.title2.bold()) - .foregroundStyle(Color.accent) - Text("/") - Text("\(achievement.totalCount)") - } - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.textSecondary) - } else { - Text("-- / --") - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.textSecondary) - } - } - } - - private var filterMenu: some View { - Menu { - Picker( - String( - localized: "today_due_visibility_label", - bundle: PresentationResources.bundle - ), - selection: $store.displayOptions.dueDateVisibility - ) { - ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in - Text(option.title).tag(option) - } + if let achievement = store.todayAchievement { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text("\(achievement.completedCount)") + .font(.title2.bold()) + .foregroundStyle(Color.accent) + Text("/") + Text("\(achievement.totalCount)") } - - Toggle( - String( - localized: "today_pinned_only", - bundle: PresentationResources.bundle - ), - isOn: $store.displayOptions.isFocusedOnly - ) - } label: { - Image(systemName: "ellipsis") - .font(.headline) + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.textSecondary) + } else { + Text("-- / --") + .font(.callout.weight(.semibold)) .foregroundStyle(Color.textSecondary) - .padding(8) } } @ViewBuilder - private var scopePicker: some View { - let summaryCounts = store.summaryCounts - + private var filterBar: some View { ScrollView(.horizontal) { - HStack(spacing: 8) { - ForEach(TodayFeature.SectionScope.allCases, id: \.self) { scope in - let isSelected = store.selectedSectionScope == scope + LazyHStack(spacing: 8) { + ForEach(TodayFeature.TodoScope.allCases, id: \.self) { scope in + let isSelected = store.selectedTodoScope == scope Button { withAnimation(.easeInOut) { - _ = store.send(.setSectionScope(scope)) + _ = store.send(.setTodoScope(scope)) } } label: { HStack(spacing: 6) { Text(scope.title) - Text("\(summaryCounts[scope, default: 0])") + Text("\(store.summaryCounts[scope, default: 0])") .fontWeight(.bold) } .font(.callout) - .foregroundStyle(isSelected ? Color.accent : Color.textSecondary) + .foregroundStyle(isSelected ? Color.accent : .textSecondary) } .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .clear) } - } - } - .scrollIndicators(.hidden) - .contentMargins(.horizontal, 16, for: .scrollContent) - } - - private var displaySections: [TodayDisplaySection] { - guard store.selectedSectionScope == .all else { - return store.sections.map(TodayDisplaySection.init) - } - - let todayItems = visibleTodayTodos - let todayItemIDs = Set(todayItems.filter { !$0.isCompleted }.map(\.id)) - let remainingSections = store.sections.compactMap { section -> TodayDisplaySection? in - let items = section.items.filter { !todayItemIDs.contains($0.id) } - guard !items.isEmpty else { return nil } - return TodayDisplaySection(section: section, items: items) - } - let order: [TodayFeature.SectionCategory] = [.overdue, .dueSoon, .focused, .later, .unscheduled] - var sections = order.compactMap { category in - remainingSections.first { $0.category == category } - } - - guard !todayItems.isEmpty else { return sections } - let todaySection = TodayDisplaySection(todayItems: todayItems) - let insertionIndex = sections.firstIndex { $0.category != .overdue } ?? sections.endIndex - sections.insert(todaySection, at: insertionIndex) - return sections - } - private var visibleTodayTodos: [TodayTodoItem] { - let items = TodayFeature.displayedTodos( - todos: store.todayTodos, - displayOptions: store.displayOptions - ) - return items.filter(\.isPinned) + items.filter { !$0.isPinned } - } - - private func todoSection(_ section: TodayDisplaySection) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Text(section.title) - .font(.title3.bold()) - .foregroundStyle(section.accentColor) - Text("\(section.items.count)") - .font(.callout.bold()) - .foregroundStyle(section.accentColor) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(section.accentColor.opacity(0.1), in: .capsule) - Spacer() - if section.id == .today { - Text(String(localized: "today_pinned_first", bundle: PresentationResources.bundle)) - .font(.callout) - .foregroundStyle(Color.textSecondary) - } - } - - LazyVStack(spacing: 12) { - ForEach(section.items) { item in - TodayTodoCard( - item: item, - onComplete: { store.send(.completeTodo(item)) } - ) + let category = store.selectedCategory + let isSelected = category != nil + Button { + store.send(.binding(.set(\.isCategoryFilterPresented, true))) + } label: { + HStack(spacing: 6) { + Image(systemName: category?.symbolName ?? "tray.2") + .foregroundStyle(category?.color ?? Color.textSecondary) + Text( + category?.localizedName + ?? String( + localized: "todo_category", + bundle: PresentationResources.bundle + ) + ) + } + .font(.callout) + .foregroundStyle(isSelected ? Color.accent : .textSecondary) } + .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .clear) } } + .scrollIndicators(.hidden) + .padding(.horizontal, -16) + .contentMargins(.horizontal, 16, for: .scrollContent) } private var emptyContent: some View { @@ -321,34 +251,22 @@ public struct TodayView: View { } private var emptyStateContent: EmptyStateContent { - switch store.selectedSectionScope { - case .all: - if store.todos.isEmpty { - return EmptyStateContent( - title: String(localized: "today_empty_all_title", bundle: PresentationResources.bundle), - message: String(localized: "today_empty_all_message", bundle: PresentationResources.bundle) - ) - } - return EmptyStateContent( - title: String(localized: "today_empty_filtered_title", bundle: PresentationResources.bundle), - message: String(localized: "today_empty_filtered_message", bundle: PresentationResources.bundle) - ) - case .focused: - return EmptyStateContent( - title: String(localized: "today_empty_focused_title", bundle: PresentationResources.bundle), - message: String(localized: "today_empty_focused_message", bundle: PresentationResources.bundle) - ) - case .overdue: + if store.todos.isEmpty, store.completedTodayTodos.isEmpty { return EmptyStateContent( - title: String(localized: "today_empty_overdue_title", bundle: PresentationResources.bundle), - message: String(localized: "today_empty_overdue_message", bundle: PresentationResources.bundle) + title: String(localized: "today_empty_all_title", bundle: PresentationResources.bundle), + message: String(localized: "today_empty_all_message", bundle: PresentationResources.bundle) ) - case .dueSoon: + } + if store.hasActiveFilters { return EmptyStateContent( - title: String(localized: "today_empty_due_soon_title", bundle: PresentationResources.bundle), - message: String(localized: "today_empty_due_soon_message", bundle: PresentationResources.bundle) + title: String(localized: "today_empty_filtered_title", bundle: PresentationResources.bundle), + message: String(localized: "today_empty_filtered_message", bundle: PresentationResources.bundle) ) } + return EmptyStateContent( + title: String(localized: "today_empty_all_title", bundle: PresentationResources.bundle), + message: String(localized: "today_empty_all_message", bundle: PresentationResources.bundle) + ) } private struct EmptyStateContent { @@ -357,42 +275,138 @@ public struct TodayView: View { } } -private struct TodayDisplaySection: Identifiable { - enum Identifier: Hashable { - case today - case category(TodayFeature.SectionCategory) +private struct CategoryFilterSheet: View { + let store: StoreOf + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 0) { + CategoryFilterRow( + category: nil, + title: String( + localized: "today_filter_all_categories", + bundle: PresentationResources.bundle + ), + isSelected: store.selectedCategoryID == nil, + action: { store.send(.setCategory(nil)) } + ) + if !store.visibleCategories.isEmpty { + Divider() + } + ForEach( + Array(zip(store.visibleCategories.indices, store.visibleCategories)), + id: \.1.id + ) { index, category in + CategoryFilterRow( + category: category, + title: category.localizedName, + isSelected: store.selectedCategoryID == category.id, + action: { store.send(.setCategory(category.id)) } + ) + if index < store.visibleCategories.count - 1 { + Divider() + } + } + } + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + .padding() + } + .background(Color.appBackground.ignoresSafeArea()) + .navigationTitle(String(localized: "todo_category", bundle: PresentationResources.bundle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + store.send(.binding(.set(\.isCategoryFilterPresented, false))) + } label: { + Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) + } + } + } + } + .presentationDragIndicator(.visible) } +} - let id: Identifier - let category: TodayFeature.SectionCategory? +private struct CategoryFilterRow: View { + let category: TodoCategoryItem? let title: String - let items: [TodayTodoItem] - let accentColor: Color + let isSelected: Bool + let action: () -> Void - init(_ section: TodayFeature.SectionContent) { - self.init(section: section, items: section.items) + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: category?.symbolName ?? "tray.2") + .foregroundStyle(category?.color ?? Color.textSecondary) + Text(title) + Spacer() + if isSelected { + Image(systemName: "checkmark") + } + } + .font(.callout) + .foregroundStyle(isSelected ? Color.accent : .textSecondary) + .frame(maxWidth: .infinity) + .padding() + .contentShape(.rect) + } + .buttonStyle(.plain) } +} - init(section: TodayFeature.SectionContent, items: [TodayTodoItem]) { - self.id = .category(section.category) - self.category = section.category - self.title = section.category.displayTitle - self.items = items - self.accentColor = section.category == .overdue ? .danger : .primary - } +private struct TodoSection: View { + let section: TodayFeature.SectionContent + let onComplete: (TodayTodoItem) -> Void - init(todayItems: [TodayTodoItem]) { - self.id = .today - self.category = nil - self.title = String(localized: "today_section_today", bundle: PresentationResources.bundle) - self.items = todayItems - self.accentColor = .primary + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text(section.title) + .font(.title3.bold()) + .foregroundStyle(section.category.accentColor) + Text("\(section.items.count)") + .font(.callout.bold()) + .foregroundStyle(section.category.accentColor) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(section.category.accentColor.opacity(0.1), in: .capsule) + Spacer() + if section.category == .today { + Text(String(localized: "today_pinned_first", bundle: PresentationResources.bundle)) + .font(.callout) + .foregroundStyle(Color.textSecondary) + } + } + + LazyVStack(spacing: 0) { + ForEach(Array(zip(section.items.indices, section.items)), id: \.1.id) { index, item in + TodoRow( + item: item, + onComplete: { onComplete(item) } + ) + if index < section.items.count - 1 { + Divider() + } + } + } + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + } } } -private struct TodayTodoCard: View { +private struct TodoRow: View { @Environment(\.colorScheme) private var colorScheme - @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(30) + @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(24) let item: TodayTodoItem let onComplete: () -> Void private var isDarkMode: Bool { colorScheme == .dark } @@ -400,7 +414,6 @@ private struct TodayTodoCard: View { var body: some View { HStack(alignment: .top, spacing: 12) { completionStatus - NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 8) { @@ -429,11 +442,6 @@ private struct TodayTodoCard: View { .todoDetailPreview(todoId: item.id) } .padding(20) - .background { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) - } } @ViewBuilder @@ -504,48 +512,20 @@ private struct TodayTodoCard: View { } } -private extension TodayDisplayOptions.DueDateVisibility { +private extension TodayFeature.TodoScope { var title: String { switch self { - case .all: - return String(localized: "today_due_visibility_all", bundle: PresentationResources.bundle) - case .withDueDateOnly: - return String(localized: "today_due_visibility_with_due", bundle: PresentationResources.bundle) - case .withoutDueDateOnly: - return String(localized: "today_due_visibility_without_due", bundle: PresentationResources.bundle) - } - } -} - -private extension TodayFeature.SectionScope { - var title: String { - switch self { - case .all: - return String(localized: "today_summary_all", bundle: PresentationResources.bundle) - case .focused: - return String(localized: "today_summary_focused", bundle: PresentationResources.bundle) - case .overdue: - return String(localized: "today_summary_overdue", bundle: PresentationResources.bundle) - case .dueSoon: - return String(localized: "today_summary_due_soon", bundle: PresentationResources.bundle) + case .remaining: + return String(localized: "today_filter_remaining", bundle: PresentationResources.bundle) + case .important: + return String(localized: "today_filter_important", bundle: PresentationResources.bundle) } } } private extension TodayFeature.SectionCategory { - var displayTitle: String { - switch self { - case .overdue: - return String(localized: "today_section_overdue", bundle: PresentationResources.bundle) - case .dueSoon: - return String(localized: "today_section_upcoming", bundle: PresentationResources.bundle) - case .focused: - return String(localized: "today_section_focused", bundle: PresentationResources.bundle) - case .later: - return String(localized: "today_section_later", bundle: PresentationResources.bundle) - case .unscheduled: - return String(localized: "today_section_unscheduled", bundle: PresentationResources.bundle) - } + var accentColor: Color { + self == .overdue ? .danger : .primary } } diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift index c82c93c8..d930d2e4 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift @@ -14,7 +14,7 @@ import Domain @MainActor struct TodayFeatureSupportTests { @Test("TodayTodoItem은 예정 화면에 필요한 완료 상태와 내용을 보존한다") - func TodayTodoItem은_예정_화면에_필요한_완료_상태와_내용을_보존한다() throws { + func todayTodoItem은_예정_화면에_필요한_완료_상태와_내용을_보존한다() throws { let todo = makeTodayTodo( isCompleted: true, title: "제목", @@ -29,7 +29,7 @@ struct TodayFeatureSupportTests { } @Test("TodayFeature achievement는 오늘 마감 Todo만 집계한다") - func TodayFeature_achievement는_오늘_마감_Todo만_집계한다() throws { + func todayFeature_achievement는_오늘_마감_Todo만_집계한다() throws { let now = try #require(makeAchievementFixedNow()) let calendar = Calendar.current let interval = TodayFeature.dayInterval(containing: now, calendar: calendar) @@ -64,7 +64,7 @@ struct TodayFeatureSupportTests { } @Test("TodayFeature achievement는 대상 없음과 전체 완료 상태를 구분한다") - func TodayFeature_achievement는_대상_없음과_전체_완료_상태를_구분한다() throws { + func todayFeature_achievement는_대상_없음과_전체_완료_상태를_구분한다() throws { let now = try #require(makeAchievementFixedNow()) let interval = TodayFeature.dayInterval(containing: now) let empty = TodayFeature.achievement( @@ -91,7 +91,7 @@ struct TodayFeatureSupportTests { } @Test("TodayFeature dayInterval은 일광 절약 시간에도 다음 달력 날짜를 경계로 사용한다") - func TodayFeature_dayInterval은_일광_절약_시간에도_다음_달력_날짜를_경계로_사용한다() throws { + func todayFeature_dayInterval은_일광_절약_시간에도_다음_달력_날짜를_경계로_사용한다() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 8, hour: 12))) @@ -103,8 +103,8 @@ struct TodayFeatureSupportTests { #expect(calendar.component(.day, from: interval.end) == 9) } - @Test("TodayFeature setSectionScope는 동일 탭 재선택 시 all로 되돌린다") - func TodayFeature_setSectionScope는_동일_탭_재선택_시_all로_되돌린다() async throws { + @Test("TodayFeature setTodoScope는 남은 일과 중요 보기를 전환한다") + func todayFeature_setTodoScope는_남은_일과_중요_보기를_전환한다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -114,33 +114,61 @@ struct TodayFeatureSupportTests { ) let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) - try await verifyTodaySectionScopeToggle(adapter: adapter, fetchUseCaseSpy: fetchSpy) + try await verifyTodayTodoScope(adapter: adapter, fetchUseCaseSpy: fetchSpy) } - @Test("TodayFeature displayOptions 변경은 필터링 결과와 저장 상태를 갱신한다") - func TodayFeature_displayOptions_변경은_필터링_결과와_저장_상태를_갱신한다() async throws { - let todos = makeTodaySectionTodos() + @Test("TodayFeature 카테고리는 저장 순서와 단일 선택을 유지한다") + func todayFeature_카테고리는_저장_순서와_단일_선택을_유지한다() async throws { + let now = Date() + let categories = [ + TodoCategoryPreference(category: .system(.doc), isVisible: true), + TodoCategoryPreference(category: .system(.feature), isVisible: true), + TodoCategoryPreference(category: .system(.issue), isVisible: false) + ] + let todos = [ + makeTodayTodo(id: "swift", isPinned: true, dueDate: now, category: .system(.feature)), + makeTodayTodo(id: "ios", dueDate: now, category: .system(.feature)), + makeTodayTodo(id: "doc", dueDate: now, category: .system(.doc)) + ] let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) ] ) - let updateSpy = TodayUpdateDisplayOptionsUseCaseSpy() let adapter = TodayStoreTestAdapter( fetchUseCase: fetchSpy, - updateDisplayOptionsUseCase: updateSpy + fetchCategoryPreferencesUseCase: TodayFetchCategoryPreferencesUseCaseSpy( + preferences: categories + ), + now: now ) - try await verifyTodayDisplayOptions( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - updateDisplayOptionsUseCaseSpy: updateSpy - ) + await adapter.fetchData() + await adapter.setCategory(SystemTodoCategory.feature.rawValue) + + #expect(adapter.visibleCategoryIDs == ["doc", "feature"]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .today, itemIds: ["swift", "ios"]) + ]) + + await adapter.setTodoScope(.important) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .today, itemIds: ["swift"]) + ]) + + await adapter.setCategoryFilterPresented(true) + #expect(adapter.isCategoryFilterPresented) + + await adapter.setTodoScope(.remaining) + await adapter.setCategory(nil) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .today, itemIds: ["swift", "ios", "doc"]) + ]) } @Test("TodayFeature completeTodo는 Todo를 제거하고 완료 이벤트를 남긴다") - func TodayFeature_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { + func todayFeature_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -168,13 +196,35 @@ struct TodayFeatureSupportTests { } @Test("TodayFeature fetchData 실패는 에러 표시 상태를 만든다") - func TodayFeature_fetchData_실패는_에러_표시_상태를_만든다() async { + func todayFeature_fetchData_실패는_에러_표시_상태를_만든다() async { let fetchSpy = TodayFetchTodosUseCaseSpy() fetchSpy.error = TodayTestError.failure let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy) await verifyTodayFetchFailureShowsAlert(adapter: adapter) } + + @Test("TodayFeature 카테고리 조회 실패는 Todo 조회 결과를 유지한다") + func todayFeature_카테고리_조회_실패는_Todo_조회_결과를_유지한다() async { + let todo = makeTodayTodo(dueDate: Date()) + let fetchSpy = TodayFetchTodosUseCaseSpy( + pagesByFilter: [ + .withDueDate: .init(items: [todo], nextCursor: nil), + .withoutDueDate: .init(items: [], nextCursor: nil) + ] + ) + let categorySpy = TodayFetchCategoryPreferencesUseCaseSpy() + categorySpy.error = TodayTestError.failure + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchCategoryPreferencesUseCase: categorySpy + ) + + await adapter.fetchData() + + #expect(adapter.todos.map(\.id) == [todo.id]) + #expect(adapter.showAlert) + } } private func makeAchievementFixedNow() -> Date? { diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift index fb174961..f6c03727 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift @@ -61,91 +61,35 @@ func verifyTodayFetchData( #expect(cursors.allSatisfy { $0 == nil }) #expect(adapter.todos.map(\.id) == ["focused", "overdue", "due-soon", "later", "unscheduled"]) #expect(adapter.summaryCounts == [ - .all: 5, - .focused: 1, - .overdue: 1, - .dueSoon: 2 + .remaining: 5, + .important: 1 ]) #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused"]), TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), - TodayDisplayedSection(category: .dueSoon, itemIds: ["due-soon"]), + TodayDisplayedSection(category: .upcoming, itemIds: ["focused", "due-soon"]), TodayDisplayedSection(category: .later, itemIds: ["later"]), TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) ]) } @MainActor -func verifyTodaySectionScopeToggle( +func verifyTodayTodoScope( adapter: Adapter, fetchUseCaseSpy: TodayFetchTodosUseCaseSpy ) async throws { try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) - await adapter.setSectionScope(.focused) + await adapter.setTodoScope(.important) - #expect(adapter.selectedSectionScope == .focused) + #expect(adapter.selectedTodoScope == .important) #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused"]) + TodayDisplayedSection(category: .upcoming, itemIds: ["focused"]) ]) - await adapter.setSectionScope(.focused) + await adapter.setTodoScope(.remaining) - #expect(adapter.selectedSectionScope == .all) - #expect(adapter.displayedSections.count == 5) - - await adapter.setSectionScope(.overdue) - - #expect(adapter.selectedSectionScope == .overdue) - #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]) - ]) -} - -@MainActor -func verifyTodayDisplayOptions( - adapter: Adapter, - fetchUseCaseSpy: TodayFetchTodosUseCaseSpy, - updateDisplayOptionsUseCaseSpy: TodayUpdateDisplayOptionsUseCaseSpy -) async throws { - try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) - - await adapter.setDueDateVisibility(.withoutDueDateOnly) - - #expect(adapter.displayOptions.dueDateVisibility == .withoutDueDateOnly) - #expect(adapter.summaryCounts == [ - .all: 1, - .focused: 0, - .overdue: 0, - .dueSoon: 0 - ]) - #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) - ]) - - await adapter.setDueDateVisibility(.all) - await adapter.setFocusVisibility(.focusedOnly) - - #expect(adapter.displayOptions.focusVisibility == .focusedOnly) - #expect(adapter.summaryCounts == [ - .all: 1, - .focused: 1, - .overdue: 0, - .dueSoon: 1 - ]) - #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused"]) - ]) - - await adapter.resetDisplayOptions() - - #expect(adapter.displayOptions == .default) - #expect(updateDisplayOptionsUseCaseSpy.options == [ - TodayDisplayOptions(dueDateVisibility: .withoutDueDateOnly, focusVisibility: .all), - TodayDisplayOptions(dueDateVisibility: .all, focusVisibility: .all), - TodayDisplayOptions(dueDateVisibility: .all, focusVisibility: .focusedOnly), - .default - ]) + #expect(adapter.selectedTodoScope == .remaining) + #expect(adapter.displayedSections.count == 4) } @MainActor @@ -170,14 +114,12 @@ func verifyTodayCompleteTodo( #expect(upsertTodoUseCaseSpy.todos.last?.isCompleted == true) #expect(trackAnalyticsEventUseCaseSpy.hasTrackedTodoComplete) #expect(adapter.summaryCounts == [ - .all: 4, - .focused: 1, - .overdue: 1, - .dueSoon: 1 + .remaining: 4, + .important: 1 ]) #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused"]), TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), + TodayDisplayedSection(category: .upcoming, itemIds: ["focused"]), TodayDisplayedSection(category: .later, itemIds: ["later"]), TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) ]) diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift index 7a9374ef..320ef68d 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift @@ -7,7 +7,6 @@ import Foundation import PresentationShared -import Core import Domain @testable import TodayTab @@ -15,19 +14,17 @@ enum TodayTestError: Error { case failure } -enum TodayTestSectionScope: Hashable, CaseIterable { - case all - case focused - case overdue - case dueSoon +enum TodayTestTodoScope: Hashable, CaseIterable { + case remaining + case important } enum TodayTestSectionCategory: Hashable { + case overdue + case today + case upcoming case later case unscheduled - case focused - case overdue - case dueSoon } struct TodayDisplayedSection: Equatable { @@ -43,19 +40,20 @@ protocol TodayStateDriving { var todayAchievement: TodayFeature.TodayAchievement? { get } var todayInterval: DateInterval { get } var isTodayDataLoaded: Bool { get } - var selectedSectionScope: TodayTestSectionScope { get } - var displayOptions: TodayDisplayOptions { get } + var selectedTodoScope: TodayTestTodoScope { get } + var selectedCategoryID: String? { get } + var isCategoryFilterPresented: Bool { get } + var visibleCategoryIDs: [String] { get } var showAlert: Bool { get } var isLoading: Bool { get } var displayedSections: [TodayDisplayedSection] { get } - var summaryCounts: [TodayTestSectionScope: Int] { get } + var summaryCounts: [TodayTestTodoScope: Int] { get } func fetchData() async func checkCurrentDate(_ date: Date) async - func setSectionScope(_ scope: TodayTestSectionScope) async - func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async - func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async - func resetDisplayOptions() async + func setTodoScope(_ scope: TodayTestTodoScope) async + func setCategory(_ categoryID: String?) async + func setCategoryFilterPresented(_ isPresented: Bool) async func completeTodo(_ item: TodayTodoItem) async } @@ -69,12 +67,14 @@ struct TodayStoreTestAdapter: TodayStateDriving { var todayAchievement: TodayFeature.TodayAchievement? { store.state.todayAchievement } var todayInterval: DateInterval { store.state.todayInterval } var isTodayDataLoaded: Bool { store.state.isTodayDataLoaded } - var selectedSectionScope: TodayTestSectionScope { store.state.selectedSectionScope.testValue } - var displayOptions: TodayDisplayOptions { store.state.displayOptions } + var selectedTodoScope: TodayTestTodoScope { store.state.selectedTodoScope.testValue } + var selectedCategoryID: String? { store.state.selectedCategoryID } + var isCategoryFilterPresented: Bool { store.state.isCategoryFilterPresented } + var visibleCategoryIDs: [String] { store.state.visibleCategories.map(\.id) } var showAlert: Bool { store.state.alert != nil } var isLoading: Bool { store.state.isLoading } var displayedSections: [TodayDisplayedSection] { store.state.sections.map(\.testValue) } - var summaryCounts: [TodayTestSectionScope: Int] { + var summaryCounts: [TodayTestTodoScope: Int] { Dictionary( uniqueKeysWithValues: store.state.summaryCounts.map { key, value in (key.testValue, value) @@ -84,26 +84,21 @@ struct TodayStoreTestAdapter: TodayStateDriving { init( fetchUseCase: FetchTodosUseCase = TodayFetchTodosUseCaseSpy(), + fetchCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase + = TodayFetchCategoryPreferencesUseCaseSpy(), fetchTodoByIdUseCase: FetchTodoByIdUseCase = TodayFetchTodoByIdUseCaseSpy(), upsertUseCase: UpsertTodoUseCase = TodayUpsertTodoUseCaseSpy(), - fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase = TodayFetchDisplayOptionsUseCaseSpy(), - updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase = TodayUpdateDisplayOptionsUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodayTrackAnalyticsEventUseCaseSpy(), now: Date = Date(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { - store = TestStore( - initialState: TodayFeature.State( - displayOptions: fetchDisplayOptionsUseCase.execute(), - now: now - ) - ) { + store = TestStore(initialState: TodayFeature.State(now: now)) { TodayFeature() } withDependencies: { $0.todayFetchTodosUseCase = fetchUseCase + $0.fetchTodoCategoryPreferencesUseCase = fetchCategoryPreferencesUseCase $0.fetchTodoByIdUseCase = fetchTodoByIdUseCase $0.upsertTodoUseCase = upsertUseCase - $0.updateTodayDisplayOptionsUseCase = updateDisplayOptionsUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.date.now = now $0.continuousClock = ContinuousClock() @@ -145,23 +140,16 @@ struct TodayStoreTestAdapter: TodayStateDriving { await store.send(.store(.updateTodo(item))) } - func setSectionScope(_ scope: TodayTestSectionScope) async { - await store.send(.setSectionScope(scope.featureValue)) - } - - func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async { - await store.send(.binding(.set(\.displayOptions.dueDateVisibility, visibility))) - await drainReceivedActions() + func setTodoScope(_ scope: TodayTestTodoScope) async { + await store.send(.setTodoScope(scope.featureValue)) } - func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async { - await store.send(.binding(.set(\.displayOptions.focusVisibility, visibility))) - await drainReceivedActions() + func setCategory(_ categoryID: String?) async { + await store.send(.setCategory(categoryID)) } - func resetDisplayOptions() async { - await store.send(.resetDisplayOptions) - await drainReceivedActions() + func setCategoryFilterPresented(_ isPresented: Bool) async { + await store.send(.binding(.set(\.isCategoryFilterPresented, isPresented))) } func completeTodo(_ item: TodayTodoItem) async { @@ -176,32 +164,20 @@ struct TodayStoreTestAdapter: TodayStateDriving { } } -private extension TodayTestSectionScope { - var featureValue: TodayFeature.SectionScope { +private extension TodayTestTodoScope { + var featureValue: TodayFeature.TodoScope { switch self { - case .all: - return .all - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon + case .remaining: .remaining + case .important: .important } } } -private extension TodayFeature.SectionScope { - var testValue: TodayTestSectionScope { +private extension TodayFeature.TodoScope { + var testValue: TodayTestTodoScope { switch self { - case .all: - return .all - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon + case .remaining: .remaining + case .important: .important } } } @@ -209,16 +185,11 @@ private extension TodayFeature.SectionScope { private extension TodayFeature.SectionCategory { var testValue: TodayTestSectionCategory { switch self { - case .later: - return .later - case .unscheduled: - return .unscheduled - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon + case .overdue: .overdue + case .today: .today + case .upcoming: .upcoming + case .later: .later + case .unscheduled: .unscheduled } } } diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift index 43e8ee08..d5099676 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift @@ -110,19 +110,19 @@ final class TodayUpsertTodoUseCaseSpy: UpsertTodoUseCase { } } -struct TodayFetchDisplayOptionsUseCaseSpy: FetchTodayDisplayOptionsUseCase { - var options: TodayDisplayOptions = .default +final class TodayFetchCategoryPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { + var preferences: [TodoCategoryPreference] + var error: Error? + private(set) var callCount = 0 - func execute() -> TodayDisplayOptions { - options + init(preferences: [TodoCategoryPreference] = []) { + self.preferences = preferences } -} - -final class TodayUpdateDisplayOptionsUseCaseSpy: UpdateTodayDisplayOptionsUseCase { - private(set) var options = [TodayDisplayOptions]() - func execute(_ options: TodayDisplayOptions) { - self.options.append(options) + func execute() async throws -> [TodoCategoryPreference] { + callCount += 1 + if let error { throw error } + return preferences } } @@ -149,7 +149,8 @@ func makeTodayTodo( number: Int = 1, title: String = "Todo", content: String = "content", - dueDate: Date? = nil + dueDate: Date? = nil, + category: TodoCategory = .system(.feature) ) -> Todo { let now = Date(timeIntervalSince1970: 0) return Todo( @@ -166,7 +167,7 @@ func makeTodayTodo( deletedAt: nil, dueDate: dueDate, tags: [], - category: .system(.feature) + category: category ) } diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift index 9061f746..658bce23 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift @@ -13,61 +13,55 @@ import Domain @MainActor struct TodayFeatureTests { - @Test("TodayFeature groupedSectionItems는 주입된 now 기준으로 섹션을 분류한다") - func TodayFeature_groupedSectionItems는_주입된_now_기준으로_섹션을_분류한다() throws { + @Test("TodayFeature groupedSectionItems는 중요 여부와 무관하게 날짜로 분류한다") + func todayFeature_groupedSectionItems는_중요_여부와_무관하게_날짜로_분류한다() throws { let now = try #require(makeFixedTodayNow()) let items = makeFixedTodayTodoItems(now: now) + let interval = TodayFeature.dayInterval(containing: now) - let sections = TodayFeature.groupedSectionItems(from: items, now: now) + let sections = TodayFeature.groupedSectionItems(from: items, interval: interval) - #expect(sections.focused.map(\.id) == ["focused"]) #expect(sections.overdue.map(\.id) == ["overdue"]) - #expect(sections.dueSoon.map(\.id) == ["due-soon"]) + #expect(sections.today.isEmpty) + #expect(sections.upcoming.map(\.id) == ["focused", "due-soon"]) #expect(sections.later.map(\.id) == ["later"]) #expect(sections.unscheduled.map(\.id) == ["unscheduled"]) } - @Test("TodayFeature summaryValue는 주입된 now 기준으로 요약 값을 계산한다") - func TodayFeature_summaryValue는_주입된_now_기준으로_요약_값을_계산한다() throws { + @Test("TodayFeature filteredTodos는 보기와 카테고리 조건을 함께 적용한다") + func todayFeature_filteredTodos는_보기와_카테고리_조건을_함께_적용한다() throws { let now = try #require(makeFixedTodayNow()) - let todos = makeFixedTodayTodoItems(now: now) - - #expect( - TodayFeature.summaryValue( - for: .all, - todos: todos, - displayOptions: .default, - now: now - ) == 5 - ) - #expect( - TodayFeature.summaryValue( - for: .focused, - todos: todos, - displayOptions: .default, - now: now - ) == 1 - ) - #expect( - TodayFeature.summaryValue( - for: .overdue, - todos: todos, - displayOptions: .default, - now: now - ) == 1 - ) - #expect( - TodayFeature.summaryValue( - for: .dueSoon, - todos: todos, - displayOptions: .default, - now: now - ) == 2 + let todos = [ + makeTodayTodo( + id: "match", + isPinned: true, + dueDate: now, + category: .system(.feature) + ), + makeTodayTodo( + id: "other-scope", + dueDate: now, + category: .system(.feature) + ), + makeTodayTodo( + id: "other-category", + isPinned: true, + dueDate: now, + category: .system(.doc) + ) + ].compactMap(TodayTodoItem.init(from:)) + + let filtered = TodayFeature.filteredTodos( + todos, + scope: .important, + categoryID: SystemTodoCategory.feature.rawValue ) + + #expect(filtered.map(\.id) == ["match"]) } @Test("TodayFeature fetchData는 요약과 섹션 상태를 갱신한다") - func TodayFeature_fetchData는_요약과_섹션_상태를_갱신한다() async throws { + func todayFeature_fetchData는_요약과_섹션_상태를_갱신한다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -81,7 +75,7 @@ struct TodayFeatureTests { } @Test("TodayFeature fetchData는 오늘 완료 Todo를 별도 조회해 달성 상태를 만든다") - func TodayFeature_fetchData는_오늘_완료_Todo를_별도_조회해_달성_상태를_만든다() async throws { + func todayFeature_fetchData는_오늘_완료_Todo를_별도_조회해_달성_상태를_만든다() async throws { let now = try #require(makeFixedTodayNow()) let incompleteToday = makeTodayTodo(id: "incomplete-today", dueDate: now) let completedToday = makeTodayTodo(id: "completed-today", isCompleted: true, dueDate: now) @@ -100,11 +94,11 @@ struct TodayFeatureTests { #expect(adapter.todayTodos.map(\.id) == ["incomplete-today", "completed-today"]) #expect(adapter.todayAchievement?.completedCount == 1) #expect(adapter.todayAchievement?.totalCount == 2) - #expect(adapter.summaryCounts[.all] == 1) + #expect(adapter.summaryCounts[.remaining] == 1) } @Test("TodayFeature checkCurrentDate는 날짜가 바뀔 때만 오늘 완료 Todo를 다시 조회한다") - func TodayFeature_checkCurrentDate는_날짜가_바뀔_때만_오늘_완료_Todo를_다시_조회한다() async throws { + func todayFeature_checkCurrentDate는_날짜가_바뀔_때만_오늘_완료_Todo를_다시_조회한다() async throws { let now = try #require(makeFixedTodayNow()) let calendar = Calendar.current let nextDay = try #require(calendar.date(byAdding: .day, value: 1, to: now)) @@ -135,7 +129,7 @@ struct TodayFeatureTests { } @Test("TodayFeature는 이전 날짜의 완료 조회 결과를 반영하지 않는다") - func TodayFeature는_이전_날짜의_완료_조회_결과를_반영하지_않는다() async throws { + func todayFeature는_이전_날짜의_완료_조회_결과를_반영하지_않는다() async throws { let now = try #require(makeFixedTodayNow()) let calendar = Calendar.current let previousDay = try #require(calendar.date(byAdding: .day, value: -1, to: now)) @@ -155,7 +149,7 @@ struct TodayFeatureTests { } @Test("TodayFeature는 최초 조회 중 날짜가 바뀌면 새 날짜 전체 데이터를 다시 조회한다") - func TodayFeature는_최초_조회_중_날짜가_바뀌면_새_날짜_전체_데이터를_다시_조회한다() async throws { + func todayFeature는_최초_조회_중_날짜가_바뀌면_새_날짜_전체_데이터를_다시_조회한다() async throws { let now = try #require(makeFixedTodayNow()) let calendar = Calendar.current let nextDay = try #require(calendar.date(byAdding: .day, value: 1, to: now)) @@ -203,7 +197,7 @@ struct TodayFeatureTests { } @Test("TodayFeature completeTodo는 오늘 Todo를 완료 목록으로 한 번만 이동한다") - func TodayFeature_completeTodo는_오늘_Todo를_완료_목록으로_한_번만_이동한다() async throws { + func todayFeature_completeTodo는_오늘_Todo를_완료_목록으로_한_번만_이동한다() async throws { let now = try #require(makeFixedTodayNow()) let todo = makeTodayTodo(id: "today", dueDate: now) let fetchSpy = TodayFetchTodosUseCaseSpy( @@ -234,7 +228,7 @@ struct TodayFeatureTests { } @Test("TodayFeature completeTodo는 저장된 최신 마감일로 오늘 완료 목록을 갱신한다") - func TodayFeature_completeTodo는_저장된_최신_마감일로_오늘_완료_목록을_갱신한다() async throws { + func todayFeature_completeTodo는_저장된_최신_마감일로_오늘_완료_목록을_갱신한다() async throws { let now = try #require(makeFixedTodayNow()) let tomorrow = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) let original = makeTodayTodo(id: "today", dueDate: now) @@ -262,7 +256,7 @@ struct TodayFeatureTests { } @Test("TodayFeature completeTodo 실패는 오늘 달성 상태를 유지한다") - func TodayFeature_completeTodo_실패는_오늘_달성_상태를_유지한다() async throws { + func todayFeature_completeTodo_실패는_오늘_달성_상태를_유지한다() async throws { let now = try #require(makeFixedTodayNow()) let todo = makeTodayTodo(id: "today", dueDate: now) let fetchSpy = TodayFetchTodosUseCaseSpy( @@ -291,8 +285,8 @@ struct TodayFeatureTests { #expect(adapter.showAlert) } - @Test("TodayFeature 달성 상태는 isChecked와 표시 옵션의 영향을 받지 않는다") - func TodayFeature_달성_상태는_isChecked와_표시_옵션의_영향을_받지_않는다() async throws { + @Test("TodayFeature 달성 상태는 목록 필터의 영향을 받지 않는다") + func todayFeature_달성_상태는_목록_필터의_영향을_받지_않는다() async throws { let now = try #require(makeFixedTodayNow()) let checkedTodo = makeTodayTodo(id: "checked", isChecked: true, dueDate: now) let completedTodo = makeTodayTodo(id: "completed", isCompleted: true, dueDate: now) @@ -306,8 +300,8 @@ struct TodayFeatureTests { let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy, now: now) await adapter.fetchData() - await adapter.setDueDateVisibility(.withoutDueDateOnly) - await adapter.setFocusVisibility(.focusedOnly) + await adapter.setTodoScope(.important) + await adapter.setCategory(SystemTodoCategory.doc.rawValue) #expect(adapter.todayAchievement?.completedCount == 1) #expect(adapter.todayAchievement?.totalCount == 2) From 9656a4740b83eec48f6345b31b393996a5d176ff Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 13:18:07 +0900 Subject: [PATCH 09/20] =?UTF-8?q?fix:=20=EB=82=B4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=95=88=EB=90=98=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TodayTab/Sources/TodayView.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 00bc6a40..ccd4cf25 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -41,6 +41,7 @@ public struct TodayView: View { ForEach(store.sections) { section in TodoSection( section: section, + onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, onComplete: { store.send(.completeTodo($0)) } ) } @@ -340,7 +341,9 @@ private struct CategoryFilterRow: View { let action: () -> Void var body: some View { - Button(action: action) { + Button { + action() + } label: { HStack(spacing: 6) { Image(systemName: category?.symbolName ?? "tray.2") .foregroundStyle(category?.color ?? Color.textSecondary) @@ -362,6 +365,7 @@ private struct CategoryFilterRow: View { private struct TodoSection: View { let section: TodayFeature.SectionContent + let onSelect: (TodayTodoItem) -> Void let onComplete: (TodayTodoItem) -> Void var body: some View { @@ -388,6 +392,7 @@ private struct TodoSection: View { ForEach(Array(zip(section.items.indices, section.items)), id: \.1.id) { index, item in TodoRow( item: item, + onSelect: { onSelect(item) }, onComplete: { onComplete(item) } ) if index < section.items.count - 1 { @@ -408,13 +413,16 @@ private struct TodoRow: View { @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(24) let item: TodayTodoItem + let onSelect: () -> Void let onComplete: () -> Void private var isDarkMode: Bool { colorScheme == .dark } var body: some View { HStack(alignment: .top, spacing: 12) { completionStatus - NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { + Button { + onSelect() + } label: { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 8) { todoInformation @@ -437,9 +445,9 @@ private struct TodoRow: View { .font(.callout.bold()) .foregroundStyle(Color.textTertiary) } + .todoDetailPreview(todoId: item.id) } .buttonStyle(.plain) - .todoDetailPreview(todoId: item.id) } .padding(20) } @@ -453,7 +461,9 @@ private struct TodoRow: View { .foregroundStyle(Color.success) .frame(width: completionSize, height: completionSize) } else { - Button(action: onComplete) { + Button { + onComplete() + } label: { Image(systemName: "circle") .resizable() .scaledToFit() From 57fd02e2973a73055f997f4191296a7c3ed6b453 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 13:41:08 +0900 Subject: [PATCH 10/20] =?UTF-8?q?ui:=20=ED=88=B4=EB=B0=94=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=ED=95=9C=20=EB=B2=84=ED=8A=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A6=AC=ED=80=B4=EB=93=9C=20=ED=9A=A8=EA=B3=BC=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 --- .../HomeTab/Sources/Search/SearchView.swift | 2 +- .../Sources/Extension/View+.swift | 43 ++++++++++++++----- .../Sources/Todo/Editor/TodoEditorView.swift | 10 ++--- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index c39eaa06..94d0137b 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -58,7 +58,7 @@ struct SearchView: View { .font(.title) .padding(6) } - .adaptiveButtonStyle(shape: .circle, color: .border) + .adaptiveButtonStyle(shape: .circle, color: .border, glassEffect: .enabled) SearchField(store: store) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+.swift index 0e239904..469a717f 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+.swift @@ -7,6 +7,11 @@ import SwiftUI +public enum AdaptiveButtonGlassEffect { + case enabled + case disabled +} + public extension View { @ViewBuilder func onScrollOffsetChange(action: @escaping (CGFloat) -> Void) -> some View { @@ -95,21 +100,39 @@ public extension View { @ViewBuilder func adaptiveButtonStyle( shape: S = Capsule(), - color: Color = .clear + color: Color = .clear, + glassEffect: AdaptiveButtonGlassEffect = .disabled ) -> some View { if #available(iOS 26.0, *) { - self.foregroundStyle(Color(.label)) - .padding(8) - .glassEffect(.regular.tint(color), in: shape) - .clipShape(shape) + switch glassEffect { + case .enabled: + self.foregroundStyle(Color(.label)) + .padding(8) + .glassEffect(.regular.tint(color), in: shape) + .clipShape(shape) + case .disabled: + opaqueButtonStyle(shape: shape, color: color) + } } else { - self.foregroundStyle(Color(.label)) - .padding(8) - .background { + opaqueButtonStyle(shape: shape, color: color) + } + } + + private func opaqueButtonStyle( + shape: S, + color: Color + ) -> some View { + self.foregroundStyle(Color(.label)) + .padding(8) + .background { + shape + .fill(color == .clear ? Color(.systemGray5) : color) + } + .overlay { + if color == .clear { shape - .fill(color == .clear ? Color(.systemGray5) : color) .strokeBorder(Color.white.opacity(0.2), lineWidth: 1) } - } + } } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index aaad1969..b58b7abf 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -182,7 +182,7 @@ private struct ToolBar: View { } } .font(.title) - .adaptiveButtonStyle(shape: .circle) + .adaptiveButtonStyle(shape: .circle, glassEffect: .enabled) Spacer() Text(store.navigationTitle) .font(.title3.bold()) @@ -214,11 +214,11 @@ private struct EditorToolbarActions: View { .foregroundStyle(Color.primary) } .font(.title) - .adaptiveButtonStyle(shape: .circle, color: Color.surface) + .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) if store.isLoading { ProgressView() .frame(width: iconSize, height: iconSize) - .adaptiveButtonStyle(shape: .circle, color: Color.surface) + .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) } else { Button { onSubmit() @@ -233,7 +233,7 @@ private struct EditorToolbarActions: View { } } .font(.title) - .adaptiveButtonStyle(shape: .circle, color: Color.surface) + .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) .disabled(!store.isReadyToSubmit) } } @@ -417,7 +417,7 @@ private struct InspectorView: View { .foregroundStyle(Color.primary) } .font(.title) - .adaptiveButtonStyle(shape: .circle, color: Color.surface) + .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) } } .padding(.horizontal) From 91a13d501f10769a1e23c3d356784d43e8024aca Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 14:41:08 +0900 Subject: [PATCH 11/20] =?UTF-8?q?ui:=20border=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/HomeTab/Sources/Search/SearchView.swift | 2 -- .../Sources/Common/Component/Toast.swift | 7 ------- Application/Presentation/TodayTab/Sources/TodayView.swift | 4 ---- 3 files changed, 13 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index 94d0137b..a1ea0996 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -234,7 +234,6 @@ private struct SearchResults: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } if store.shouldShowMoreTodos { Button { @@ -355,7 +354,6 @@ private struct RecentSearchQuries: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } } .frame(maxWidth: .infinity) diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift index e8bd6ca1..76698a59 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift @@ -336,12 +336,5 @@ private struct ToastCardView: View { .fill(.ultraThinMaterial) } } - .overlay { - if #unavailable(iOS 26.0) { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color(.systemGray4).opacity(0.2), lineWidth: 1) - } - } - .shadow(color: Color(.systemGray2).opacity(0.4), radius: 18, x: 0, y: 10) } } diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index ccd4cf25..78ea4fe5 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -126,7 +126,6 @@ public struct TodayView: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } } @@ -213,7 +212,6 @@ public struct TodayView: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } } @@ -313,7 +311,6 @@ private struct CategoryFilterSheet: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } .padding() } @@ -403,7 +400,6 @@ private struct TodoSection: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } } } From f8e2238fb5e96ad093f7e7abff127a553f15893d Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 12 Sep 2026 17:41:25 +0900 Subject: [PATCH 12/20] =?UTF-8?q?ui:=20=EC=83=89=EC=83=81=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeTab/Sources/Search/SearchView.swift | 12 +++--- .../Sources/PushNotificationListView.swift | 27 ++++++------- .../Sources/Extension/Color+Assets.swift | 6 +-- .../Sources/Todo/List/TodoListView.swift | 23 +++++------ .../Sources/Profile/ProfileView.swift | 2 +- .../TodayTab/Sources/TodayView.swift | 23 +++++------ .../AppBackground.colorset/Contents.json | 2 +- .../Contents.json | 12 +++--- .../Contents.json | 12 +++--- .../OnPrimaryContainer.colorset/Contents.json | 12 +++--- .../PrimaryContainer.colorset/Contents.json | 8 ++-- .../SuccessContainer.colorset/Contents.json | 38 ------------------- .../Surface.colorset/Contents.json | 6 +-- .../SurfaceSecondary.colorset/Contents.json | 6 +-- .../WarningContainer.colorset/Contents.json | 38 ------------------- 15 files changed, 67 insertions(+), 160 deletions(-) rename Application/Shared/Resources/ColorPalette.xcassets/{DangerContainer.colorset => ControlBackground.colorset}/Contents.json (76%) rename Application/Shared/Resources/ColorPalette.xcassets/{InfoContainer.colorset => OnControlBackground.colorset}/Contents.json (76%) delete mode 100644 Application/Shared/Resources/ColorPalette.xcassets/SuccessContainer.colorset/Contents.json delete mode 100644 Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.colorset/Contents.json diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index a1ea0996..520072ad 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -86,7 +86,7 @@ struct SearchView: View { } Spacer() } - .foregroundStyle(Color.accent) + .foregroundStyle(Color.onPrimaryContainer) .padding() .background { Rectangle() @@ -188,7 +188,7 @@ private struct SearchResults: View { Int64(store.todos.count) )) .font(.callout.bold()) - .foregroundStyle(Color.accent) + .foregroundStyle(Color.onPrimaryContainer) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.primaryContainer, in: .capsule) @@ -243,7 +243,7 @@ private struct SearchResults: View { localized: "search_show_more", bundle: PresentationResources.bundle) ) - .foregroundStyle(Color.accent) + .foregroundStyle(Color.onPrimaryContainer) .font(.callout.bold()) } .adaptiveButtonStyle(color: .primaryContainer) @@ -256,21 +256,19 @@ private struct SearchResults: View { private struct SearchResultRow: View { @ScaledMetric(relativeTo: .title2) private var iconSize = CGFloat(48) - @Environment(\.colorScheme) private var colorScheme let item: SearchTodoItem - private var isDarkMode: Bool { colorScheme == .dark } var body: some View { let category = TodoCategoryItem(from: item.category) HStack(spacing: 12) { RoundedRectangle(cornerRadius: 14) - .fill(category.color.opacity(isDarkMode ? 1 : 0.2)) + .fill(category.color.opacity(0.2)) .frame(width: iconSize, height: iconSize) .overlay { Image(systemName: category.symbolName) .font(.title3.bold()) - .foregroundStyle(isDarkMode ? .white : category.color) + .foregroundStyle(category.color) } VStack(alignment: .leading, spacing: 4) { Text(item.title) diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift index c1ac687d..73dccc6e 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift @@ -10,7 +10,6 @@ import Core import PresentationShared public struct PushNotificationListView: View { - @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 @State private var headerOffset: CGFloat = 0 @@ -184,9 +183,10 @@ public struct PushNotificationListView: View { } label: { HStack(spacing: 6) { Image(systemName: "line.3.horizontal.decrease") + .foregroundStyle(Color.onControlBackground) filterBadge } - .adaptiveButtonStyle() + .adaptiveButtonStyle(color: .controlBackground) } } @@ -202,8 +202,8 @@ public struct PushNotificationListView: View { store.query.sortOrder.title ) ) - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) + .foregroundStyle(condition ? Color.onPrimaryContainer : .onControlBackground) + .adaptiveButtonStyle(color: condition ? .primaryContainer : .controlBackground) } .frame(height: headerHeight) @@ -221,8 +221,8 @@ public struct PushNotificationListView: View { Text(String(localized: "push_period", bundle: PresentationResources.bundle)) Image(systemName: "chevron.down") } - .foregroundStyle(condition ? Color(.label) : .white) - .adaptiveButtonStyle(color: condition ? .clear : .blue) + .foregroundStyle(condition ? Color.onControlBackground : .onPrimaryContainer) + .adaptiveButtonStyle(color: condition ? .controlBackground : .primaryContainer) } Button { @@ -232,26 +232,21 @@ public struct PushNotificationListView: View { } label: { let condition = store.query.unreadOnly Text(String(localized: "push_unread", bundle: PresentationResources.bundle)) - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) + .foregroundStyle(condition ? Color.onPrimaryContainer : .onControlBackground) + .adaptiveButtonStyle(color: condition ? .primaryContainer : .controlBackground) } .frame(height: headerHeight) } } private var filterBadge: some View { - let isDark = colorScheme == .dark - let blue = Color(uiColor: .systemBlue) // 흰 배경에 따른 청록색화 방지 - let textColor: Color = isDark ? blue : .white - let backgroundColor: Color = isDark ? .white : blue - - return Text("\(store.appliedFilterCount)") + Text("\(store.appliedFilterCount)") .font(.caption2.weight(.bold)) - .foregroundColor(textColor) + .foregroundStyle(Color.onPrimaryContainer) .lineLimit(1) .minimumScaleFactor(0.6) .frame(width: 20, height: 20) - .background(Circle().fill(backgroundColor)) + .background(Circle().fill(Color.primaryContainer)) } // swiftlint:disable function_body_length diff --git a/Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift b/Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift index 4da32ecc..6aedda6e 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/Color+Assets.swift @@ -11,18 +11,16 @@ public extension Color { static let accent = Color("AccentColor", bundle: PresentationResources.bundle) static let appBackground = Color("AppBackground", bundle: PresentationResources.bundle) static let border = Color("Border", bundle: PresentationResources.bundle) + static let controlBackground = Color("ControlBackground", bundle: PresentationResources.bundle) + static let onControlBackground = Color("OnControlBackground", bundle: PresentationResources.bundle) static let danger = Color("Danger", bundle: PresentationResources.bundle) - static let dangerContainer = Color("DangerContainer", bundle: PresentationResources.bundle) static let info = Color("Info", bundle: PresentationResources.bundle) - static let infoContainer = Color("InfoContainer", bundle: PresentationResources.bundle) static let onPrimaryContainer = Color("OnPrimaryContainer", bundle: PresentationResources.bundle) static let primaryContainer = Color("PrimaryContainer", bundle: PresentationResources.bundle) static let success = Color("Success", bundle: PresentationResources.bundle) - static let successContainer = Color("SuccessContainer", bundle: PresentationResources.bundle) static let surface = Color("Surface", bundle: PresentationResources.bundle) static let surfaceSecondary = Color("SurfaceSecondary", bundle: PresentationResources.bundle) static let textSecondary = Color("TextSecondary", bundle: PresentationResources.bundle) static let textTertiary = Color("TextTertiary", bundle: PresentationResources.bundle) static let warning = Color("Warning", bundle: PresentationResources.bundle) - static let warningContainer = Color("WarningContainer", bundle: PresentationResources.bundle) } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift index cb5fd48b..35ae4a05 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -13,7 +13,6 @@ import Domain public struct TodoListView: View { @Environment(\.isTabContentActive) private var isTabContentActive - @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @@ -335,9 +334,10 @@ public struct TodoListView: View { } label: { HStack(spacing: 6) { Image(systemName: "line.3.horizontal.decrease") + .foregroundStyle(Color.onControlBackground) filterBadge } - .adaptiveButtonStyle() + .adaptiveButtonStyle(color: .controlBackground) } } @@ -368,8 +368,8 @@ public struct TodoListView: View { ) Image(systemName: "chevron.down") } - .foregroundStyle(condition ? Color(.label) : .white) - .adaptiveButtonStyle(color: condition ? .clear : .blue) + .foregroundStyle(condition ? Color.onControlBackground : .onPrimaryContainer) + .adaptiveButtonStyle(color: condition ? .controlBackground : .primaryContainer) } Menu { @@ -390,8 +390,8 @@ public struct TodoListView: View { Text(String(localized: "todo_list_filter_options", bundle: PresentationResources.bundle)) Image(systemName: "chevron.down") } - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) + .foregroundStyle(condition ? Color.onPrimaryContainer : .onControlBackground) + .adaptiveButtonStyle(color: condition ? .primaryContainer : .controlBackground) } } } @@ -409,18 +409,13 @@ public struct TodoListView: View { } private var filterBadge: some View { - let isDark = colorScheme == .dark - let blue = Color(uiColor: .systemBlue) - let textColor: Color = isDark ? blue : .white - let backgroundColor: Color = isDark ? .white : blue - - return Text("\(store.appliedFilterCount)") + Text("\(store.appliedFilterCount)") .font(.caption2.weight(.bold)) - .foregroundColor(textColor) + .foregroundStyle(Color.onPrimaryContainer) .lineLimit(1) .minimumScaleFactor(0.6) .frame(width: 20, height: 20) - .background(Circle().fill(backgroundColor)) + .background(Circle().fill(Color.primaryContainer)) } private func selectTodo(_ todoId: String) { diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index e1c45f73..cd2c5ea6 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -409,7 +409,7 @@ private struct ProfileCard: View { .transition(.move(edge: .trailing).combined(with: .opacity)) } } - .foregroundStyle(Color.gray) + .foregroundStyle(Color.onPrimaryContainer) .padding(8) .background( RoundedRectangle(cornerRadius: 10) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 78ea4fe5..78a09d58 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -33,7 +33,7 @@ public struct TodayView: View { public var body: some View { NavigationStack(path: $path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 24, pinnedViews: [.sectionHeaders]) { + LazyVStack(alignment: .leading, spacing: 8, pinnedViews: [.sectionHeaders]) { Section { if store.sections.isEmpty, !store.isLoading { emptyContent @@ -45,18 +45,17 @@ public struct TodayView: View { onComplete: { store.send(.completeTodo($0)) } ) } + .padding(.bottom, 12) } } header: { VStack { achievementCard filterBar } - .padding(.bottom, 6) .background(Color.appBackground) } } .padding(.horizontal) - .padding(.bottom, 24) } .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground.ignoresSafeArea()) @@ -109,7 +108,7 @@ public struct TodayView: View { Text(achievementStatusTitle) .font(.callout.weight(.semibold)) - .foregroundStyle(Color.accent) + .foregroundStyle(Color.onPrimaryContainer) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.primaryContainer, in: .capsule) @@ -165,9 +164,9 @@ public struct TodayView: View { .fontWeight(.bold) } .font(.callout) - .foregroundStyle(isSelected ? Color.accent : .textSecondary) + .foregroundStyle(isSelected ? Color.onPrimaryContainer : .onControlBackground) } - .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .clear) + .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .controlBackground) } let category = store.selectedCategory @@ -177,7 +176,7 @@ public struct TodayView: View { } label: { HStack(spacing: 6) { Image(systemName: category?.symbolName ?? "tray.2") - .foregroundStyle(category?.color ?? Color.textSecondary) + .foregroundStyle(category?.color ?? Color.onControlBackground) Text( category?.localizedName ?? String( @@ -187,9 +186,9 @@ public struct TodayView: View { ) } .font(.callout) - .foregroundStyle(isSelected ? Color.accent : .textSecondary) + .foregroundStyle(isSelected ? Color.onPrimaryContainer : .onControlBackground) } - .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .clear) + .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .controlBackground) } } .scrollIndicators(.hidden) @@ -406,12 +405,10 @@ private struct TodoSection: View { } private struct TodoRow: View { - @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(24) let item: TodayTodoItem let onSelect: () -> Void let onComplete: () -> Void - private var isDarkMode: Bool { colorScheme == .dark } var body: some View { HStack(alignment: .top, spacing: 12) { @@ -479,11 +476,11 @@ private struct TodoRow: View { Text(category.localizedName) } .font(.caption.weight(.semibold)) - .foregroundStyle(isDarkMode ? .white : category.color) + .foregroundStyle(category.color) .padding(.horizontal, 10) .padding(.vertical, 6) .background( - category.color.opacity(isDarkMode ? 1 : 0.2), + category.color.opacity(0.2), in: .rect(cornerRadius: 8) ) diff --git a/Application/Shared/Resources/ColorPalette.xcassets/AppBackground.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/AppBackground.colorset/Contents.json index cb3f5157..b5352ae1 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/AppBackground.colorset/Contents.json +++ b/Application/Shared/Resources/ColorPalette.xcassets/AppBackground.colorset/Contents.json @@ -25,7 +25,7 @@ "alpha": "1.000", "blue": "0x13", "green": "0x10", - "red": "0x0E" + "red": "0x10" } }, "idiom": "universal" diff --git a/Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/ControlBackground.colorset/Contents.json similarity index 76% rename from Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/ControlBackground.colorset/Contents.json index 912bd8a0..ddba8151 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.colorset/Contents.json +++ b/Application/Shared/Resources/ColorPalette.xcassets/ControlBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0xEE", - "green": "0xEC", - "red": "0xFE" + "blue": "0xF0", + "green": "0xED", + "red": "0xE9" } }, "idiom": "universal" @@ -23,9 +23,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0x22", - "green": "0x1E", - "red": "0x45" + "blue": "0x2E", + "green": "0x28", + "red": "0x23" } }, "idiom": "universal" diff --git a/Application/Shared/Resources/ColorPalette.xcassets/InfoContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/OnControlBackground.colorset/Contents.json similarity index 76% rename from Application/Shared/Resources/ColorPalette.xcassets/InfoContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/OnControlBackground.colorset/Contents.json index df9c5263..62533914 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/InfoContainer.colorset/Contents.json +++ b/Application/Shared/Resources/ColorPalette.xcassets/OnControlBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0xFF", - "green": "0xF3", - "red": "0xE8" + "blue": "0x6E", + "green": "0x63", + "red": "0x59" } }, "idiom": "universal" @@ -23,9 +23,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0x4A", - "green": "0x38", - "red": "0x16" + "blue": "0xB9", + "green": "0xAF", + "red": "0xA6" } }, "idiom": "universal" diff --git a/Application/Shared/Resources/ColorPalette.xcassets/OnPrimaryContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/OnPrimaryContainer.colorset/Contents.json index 73257c63..0a49a8b0 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/OnPrimaryContainer.colorset/Contents.json +++ b/Application/Shared/Resources/ColorPalette.xcassets/OnPrimaryContainer.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0x59", - "green": "0x5E", - "red": "0x11" + "blue": "0x83", + "green": "0x7D", + "red": "0x14" } }, "idiom": "universal" @@ -23,9 +23,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0xBF", - "green": "0xD4", - "red": "0x2D" + "blue": "0xC9", + "green": "0xC4", + "red": "0x55" } }, "idiom": "universal" diff --git a/Application/Shared/Resources/ColorPalette.xcassets/PrimaryContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/PrimaryContainer.colorset/Contents.json index b963049c..4a21752a 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/PrimaryContainer.colorset/Contents.json +++ b/Application/Shared/Resources/ColorPalette.xcassets/PrimaryContainer.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0xF7", - "green": "0xF7", - "red": "0xE6" + "blue": "0xF1", + "green": "0xF0", + "red": "0xDD" } }, "idiom": "universal" @@ -25,7 +25,7 @@ "alpha": "1.000", "blue": "0x3D", "green": "0x3A", - "red": "0x16" + "red": "0x17" } }, "idiom": "universal" diff --git a/Application/Shared/Resources/ColorPalette.xcassets/SuccessContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/SuccessContainer.colorset/Contents.json deleted file mode 100644 index e9abb0a8..00000000 --- a/Application/Shared/Resources/ColorPalette.xcassets/SuccessContainer.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "alpha": "1.000", - "blue": "0xEE", - "green": "0xF8", - "red": "0xE6" - } - }, - "idiom": "universal" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "color": { - "color-space": "srgb", - "components": { - "alpha": "1.000", - "blue": "0x2B", - "green": "0x35", - "red": "0x14" - } - }, - "idiom": "universal" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Application/Shared/Resources/ColorPalette.xcassets/Surface.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/Surface.colorset/Contents.json index a7c49ad2..8c7938af 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/Surface.colorset/Contents.json +++ b/Application/Shared/Resources/ColorPalette.xcassets/Surface.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0x2C", - "green": "0x24", - "red": "0x1F" + "blue": "0x1C", + "green": "0x17", + "red": "0x17" } }, "idiom": "universal" diff --git a/Application/Shared/Resources/ColorPalette.xcassets/SurfaceSecondary.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/SurfaceSecondary.colorset/Contents.json index 478ea04b..23aed945 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/SurfaceSecondary.colorset/Contents.json +++ b/Application/Shared/Resources/ColorPalette.xcassets/SurfaceSecondary.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0x3B", - "green": "0x30", - "red": "0x28" + "blue": "0x27", + "green": "0x20", + "red": "0x20" } }, "idiom": "universal" diff --git a/Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.colorset/Contents.json deleted file mode 100644 index 00ae8e5c..00000000 --- a/Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "alpha": "1.000", - "blue": "0xE5", - "green": "0xF4", - "red": "0xFF" - } - }, - "idiom": "universal" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "color": { - "color-space": "srgb", - "components": { - "alpha": "1.000", - "blue": "0x16", - "green": "0x2F", - "red": "0x3D" - } - }, - "idiom": "universal" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} From 43c919bb384c2ae7f32cae50195d90dde7093ea7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 01:31:36 +0900 Subject: [PATCH 13/20] =?UTF-8?q?feat:=20Today=20Todo=20=ED=96=89=20?= =?UTF-8?q?=EB=B0=8F=20Inspector=20=ED=8E=B8=EC=A7=91=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TodayTab/Sources/TodayFeature+State.swift | 3 + .../TodayTab/Sources/TodayFeature.swift | 39 ++++ .../TodayTab/Sources/TodayTodoItem.swift | 2 + .../TodayTab/Sources/TodayView.swift | 186 +++++++++++------- .../Tests/TodayFeatureTestDoubles.swift | 22 +++ .../TodayTab/Tests/TodayFeatureTests.swift | 34 ++++ 6 files changed, 215 insertions(+), 71 deletions(-) diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift index a171572c..b1313246 100644 --- a/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift +++ b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift @@ -39,6 +39,9 @@ extension TodayFeature { var selectedTodoScope = TodoScope.remaining var selectedCategoryID: String? var isCategoryFilterPresented = false + var isTodoInspectorPresented = false + var todoEditor: TodoEditorFeature.State? + var dismissesTodoInspectorAfterSaving = false var loading = LoadingFeature.State() var todayInterval: DateInterval var isIncompleteDataLoaded = false diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature.swift b/Application/Presentation/TodayTab/Sources/TodayFeature.swift index ffdc33b8..de6c70f9 100644 --- a/Application/Presentation/TodayTab/Sources/TodayFeature.swift +++ b/Application/Presentation/TodayTab/Sources/TodayFeature.swift @@ -49,6 +49,9 @@ struct TodayFeature { case setTodoScope(TodoScope) case setCategory(String?) case completeTodo(TodayTodoItem) + case showTodoInspector(TodayTodoItem) + case dismissTodoInspector + case todoEditor(TodoEditorFeature.Action) case store(StoreAction) case loading(LoadingFeature.Action) @@ -85,6 +88,10 @@ struct TodayFeature { switch action { case .alert: break + case .binding(\.isTodoInspectorPresented): + if !state.isTodoInspectorPresented { + dismissTodoInspector(state: &state) + } case .binding: break case .refresh: @@ -116,6 +123,24 @@ struct TodayFeature { state.selectedCategoryID = categoryID case .completeTodo(let item): return completeTodoEffect(item) + case .showTodoInspector(let item): + guard let item = latestItem(id: item.id, state: state) else { break } + state.todoEditor = TodoEditorFeature.State(todo: item.todo) + state.dismissesTodoInspectorAfterSaving = false + state.isTodoInspectorPresented = true + case .dismissTodoInspector: + dismissTodoInspector(state: &state) + case .todoEditor(.delegate(.updated(let todo))): + guard let item = TodayTodoItem(from: todo) else { + return .send(.store(.setAlert)) + } + state.dismissesTodoInspectorAfterSaving = true + return .send(.store(.updateTodo(item))) + case .todoEditor(.loading(.end(target: .default, mode: .immediate))): + guard state.dismissesTodoInspectorAfterSaving else { break } + return .send(.dismissTodoInspector) + case .todoEditor: + break case .store(.setAlert): state.alert = Self.alertState() case .store(.setTodos(let incomplete, let completedToday, let interval)): @@ -165,6 +190,9 @@ struct TodayFeature { return .none } .ifLet(\.$alert, action: \.alert) + .ifLet(\.todoEditor, action: \.todoEditor) { + TodoEditorFeature() + } } } @@ -186,6 +214,17 @@ private enum TodayFetchTodosUseCaseKey: DependencyKey { } private extension TodayFeature { + func latestItem(id: String, state: State) -> TodayTodoItem? { + state.todos.first { $0.id == id } + ?? state.completedTodayTodos.first { $0.id == id } + } + + func dismissTodoInspector(state: inout State) { + state.isTodoInspectorPresented = false + state.todoEditor = nil + state.dismissesTodoInspectorAfterSaving = false + } + func prepareTodayInterval(state: inout State, now: Date) -> DateInterval { let interval = Self.dayInterval(containing: now) guard state.todayInterval != interval else { return interval } diff --git a/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift index d9d100e5..ca1d1b42 100644 --- a/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift +++ b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift @@ -9,6 +9,7 @@ import Foundation import Domain public struct TodayTodoItem: Identifiable, Hashable { + let todo: Todo public let id: String public let number: Int public let title: String @@ -20,6 +21,7 @@ public struct TodayTodoItem: Identifiable, Hashable { public let category: TodoCategory init?(from todo: Todo) { + self.todo = todo self.id = todo.id self.number = todo.number self.title = todo.title diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 78a09d58..dc68f152 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -41,8 +41,9 @@ public struct TodayView: View { ForEach(store.sections) { section in TodoSection( section: section, + isNavigationEnabled: !store.isTodoInspectorPresented, onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, - onComplete: { store.send(.completeTodo($0)) } + onInspect: { store.send(.showTodoInspector($0)) } ) } .padding(.bottom, 12) @@ -65,6 +66,9 @@ public struct TodayView: View { CategoryFilterSheet(store: store) } } + .inspector(isPresented: $store.isTodoInspectorPresented) { + todoInspector + } .onChange(of: isSelected, initial: true) { _, isSelected in if isSelected { store.send(.fetchData) @@ -214,6 +218,17 @@ public struct TodayView: View { } } + @ViewBuilder + private var todoInspector: some View { + if let editorStore = store.scope(state: \.todoEditor, action: \.todoEditor) { + TodoEditorView( + store: editorStore, + onClose: { store.send(.dismissTodoInspector) } + ) + .inspectorColumnWidth(min: 320, ideal: 420, max: 520) + } + } + private func destination(_ route: TodayRoute) -> some View { switch route { case .todo(let item): @@ -361,8 +376,9 @@ private struct CategoryFilterRow: View { private struct TodoSection: View { let section: TodayFeature.SectionContent + let isNavigationEnabled: Bool let onSelect: (TodayTodoItem) -> Void - let onComplete: (TodayTodoItem) -> Void + let onInspect: (TodayTodoItem) -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -388,8 +404,9 @@ private struct TodoSection: View { ForEach(Array(zip(section.items.indices, section.items)), id: \.1.id) { index, item in TodoRow( item: item, + isNavigationEnabled: isNavigationEnabled, onSelect: { onSelect(item) }, - onComplete: { onComplete(item) } + onInspect: { onInspect(item) } ) if index < section.items.count - 1 { Divider() @@ -405,105 +422,79 @@ private struct TodoSection: View { } private struct TodoRow: View { - @ScaledMetric(relativeTo: .title2) private var completionSize = CGFloat(24) let item: TodayTodoItem + let isNavigationEnabled: Bool let onSelect: () -> Void - let onComplete: () -> Void + let onInspect: () -> Void var body: some View { - HStack(alignment: .top, spacing: 12) { - completionStatus - Button { - onSelect() - } label: { - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 8) { - todoInformation - Text(item.title) - .font(.headline) - .strikethrough(item.isCompleted) - .foregroundStyle(item.isCompleted ? Color.textSecondary : .primary) + Button { + guard isNavigationEnabled else { return } + onSelect() + } label: { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .font(.headline) + .strikethrough(item.isCompleted) + .foregroundStyle(item.isCompleted ? Color.textSecondary : .primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + if !item.content.isEmpty { + Text(item.content) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) .multilineTextAlignment(.leading) - if !item.content.isEmpty { - Text(item.content) - .font(.callout) - .foregroundStyle(Color.textSecondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - } } - .frame(maxWidth: .infinity, alignment: .leading) - - Image(systemName: "chevron.right") - .font(.callout.bold()) - .foregroundStyle(Color.textTertiary) + todoMetadata } - .todoDetailPreview(todoId: item.id) - } - .buttonStyle(.plain) - } - .padding(20) - } - - @ViewBuilder - private var completionStatus: some View { - if item.isCompleted { - Image(systemName: "checkmark.circle.fill") - .resizable() - .scaledToFit() - .foregroundStyle(Color.success) - .frame(width: completionSize, height: completionSize) - } else { - Button { - onComplete() - } label: { - Image(systemName: "circle") - .resizable() - .scaledToFit() + Spacer() + Image(systemName: "chevron.right") + .font(.callout.bold()) .foregroundStyle(Color.textTertiary) - .frame(width: completionSize, height: completionSize) + .opacity(isNavigationEnabled ? 1 : 0) + } + .overlay { + TodoInspectorInteractionView(action: onInspect) } - .buttonStyle(.plain) } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 14) } @ViewBuilder - private var todoInformation: some View { + private var todoMetadata: some View { let category = TodoCategoryItem(from: item.category) - HStack(spacing: 8) { - HStack(spacing: 4) { - Image(systemName: category.symbolName) - Text(category.localizedName) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(category.color) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - category.color.opacity(0.2), - in: .rect(cornerRadius: 8) - ) + HStack(spacing: 10) { + Text("#\(item.number)") + .foregroundStyle(Color.accent) + + Image(systemName: category.symbolName) + .foregroundStyle(category.color) if let dueDate = item.dueDate { HStack(spacing: 4) { Image(systemName: "clock") Text(dueDateText(dueDate)) } - .font(.caption.weight(.semibold)) .foregroundStyle(dueDateColor(dueDate)) } } + .font(.caption.weight(.semibold)) + .lineLimit(1) } private func dueDateText(_ date: Date) -> String { let calendar = Calendar.autoupdatingCurrent if calendar.isDateInToday(date) { - return date.formatted(date: .omitted, time: .shortened) + return String(localized: "today_section_today", bundle: PresentationResources.bundle) } if calendar.isDateInYesterday(date) { return String(localized: "today_due_yesterday", bundle: PresentationResources.bundle) } - return date.formatted(.dateTime.month(.abbreviated).day().weekday(.abbreviated).hour().minute()) + return date.formatted(.dateTime.month(.abbreviated).day().weekday(.abbreviated)) } private func dueDateColor(_ date: Date) -> Color { @@ -511,7 +502,60 @@ private struct TodoRow: View { if calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) { return .danger } - return .accent + return .textSecondary + } +} + +private struct TodoInspectorInteractionView: UIViewRepresentable { + let action: () -> Void + + func makeCoordinator() -> TodoInspectorInteractionCoordinator { + TodoInspectorInteractionCoordinator() + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + context.coordinator.update(action: action) + context.coordinator.install(on: view) + return view + } + + func updateUIView(_ view: UIView, context: Context) { + context.coordinator.update(action: action) + } +} + +private final class TodoInspectorInteractionCoordinator: NSObject { + private weak var view: UIView? + private var contextMenuInteraction: UIContextMenuInteraction? + private var action: (() -> Void)? + + func update(action: @escaping () -> Void) { + self.action = action + } + + func install(on view: UIView) { + guard self.view !== view else { return } + + if let contextMenuInteraction { + self.view?.removeInteraction(contextMenuInteraction) + } + + let contextMenuInteraction = UIContextMenuInteraction(delegate: self) + view.addInteraction(contextMenuInteraction) + self.view = view + self.contextMenuInteraction = contextMenuInteraction + } +} + +extension TodoInspectorInteractionCoordinator: UIContextMenuInteractionDelegate { + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + action?() + return nil } } diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift index 320ef68d..e535bc85 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift @@ -43,6 +43,8 @@ protocol TodayStateDriving { var selectedTodoScope: TodayTestTodoScope { get } var selectedCategoryID: String? { get } var isCategoryFilterPresented: Bool { get } + var isTodoInspectorPresented: Bool { get } + var todoEditorTitle: String? { get } var visibleCategoryIDs: [String] { get } var showAlert: Bool { get } var isLoading: Bool { get } @@ -55,6 +57,9 @@ protocol TodayStateDriving { func setCategory(_ categoryID: String?) async func setCategoryFilterPresented(_ isPresented: Bool) async func completeTodo(_ item: TodayTodoItem) async + func showTodoInspector(_ item: TodayTodoItem) async + func setTodoInspectorPresented(_ isPresented: Bool) async + func updateTodoFromInspector(_ todo: Todo) async } @MainActor @@ -70,6 +75,8 @@ struct TodayStoreTestAdapter: TodayStateDriving { var selectedTodoScope: TodayTestTodoScope { store.state.selectedTodoScope.testValue } var selectedCategoryID: String? { store.state.selectedCategoryID } var isCategoryFilterPresented: Bool { store.state.isCategoryFilterPresented } + var isTodoInspectorPresented: Bool { store.state.isTodoInspectorPresented } + var todoEditorTitle: String? { store.state.todoEditor?.title } var visibleCategoryIDs: [String] { store.state.visibleCategories.map(\.id) } var showAlert: Bool { store.state.alert != nil } var isLoading: Bool { store.state.isLoading } @@ -157,6 +164,21 @@ struct TodayStoreTestAdapter: TodayStateDriving { await drainReceivedActions() } + func showTodoInspector(_ item: TodayTodoItem) async { + await store.send(.showTodoInspector(item)) + } + + func setTodoInspectorPresented(_ isPresented: Bool) async { + await store.send(.binding(.set(\.isTodoInspectorPresented, isPresented))) + } + + func updateTodoFromInspector(_ todo: Todo) async { + await store.send(.todoEditor(.delegate(.updated(todo)))) + await drainReceivedActions() + await store.send(.todoEditor(.loading(.end(target: .default, mode: .immediate)))) + await drainReceivedActions() + } + private func drainReceivedActions() async { for _ in 0..<10 { await store.skipReceivedActions(strict: false) diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift index 658bce23..09160b12 100644 --- a/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift @@ -60,6 +60,40 @@ struct TodayFeatureTests { #expect(filtered.map(\.id) == ["match"]) } + @Test("TodayFeature는 선택한 Todo로 Inspector 편집 상태를 구성하고 닫는다") + func todayFeature는_선택한_Todo로_Inspector_편집_상태를_구성하고_닫는다() async throws { + let todo = makeTodayTodo(id: "inspector", title: "수정 전") + let item = try #require(TodayTodoItem(from: todo)) + let adapter = TodayStoreTestAdapter() + await adapter.receiveUpdatedTodo(item) + + await adapter.showTodoInspector(item) + + #expect(adapter.isTodoInspectorPresented) + #expect(adapter.todoEditorTitle == "수정 전") + + await adapter.setTodoInspectorPresented(false) + + #expect(!adapter.isTodoInspectorPresented) + #expect(adapter.todoEditorTitle == nil) + } + + @Test("TodayFeature는 Inspector에서 수정한 Todo를 목록에 반영하고 닫는다") + func todayFeature는_Inspector에서_수정한_Todo를_목록에_반영하고_닫는다() async throws { + var todo = makeTodayTodo(id: "inspector", title: "수정 전") + let item = try #require(TodayTodoItem(from: todo)) + let adapter = TodayStoreTestAdapter() + await adapter.receiveUpdatedTodo(item) + await adapter.showTodoInspector(item) + todo.title = "수정 후" + + await adapter.updateTodoFromInspector(todo) + + #expect(adapter.todos.first?.title == "수정 후") + #expect(!adapter.isTodoInspectorPresented) + #expect(adapter.todoEditorTitle == nil) + } + @Test("TodayFeature fetchData는 요약과 섹션 상태를 갱신한다") func todayFeature_fetchData는_요약과_섹션_상태를_갱신한다() async throws { let todos = makeTodaySectionTodos() From 07c32ca8f20128830f0a89f0e25683e71f533153 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 01:57:27 +0900 Subject: [PATCH 14/20] =?UTF-8?q?chore:=20tuist=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/config.toml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .codex/config.toml diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..b49169d5 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,8 @@ +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + "/Users/opfic/.local/state/tuist", + "/Users/opfic/.cache/tuist", + "/Users/opfic/Library/Developer/Xcode/DerivedData", +] From 6b5b7b76eb336fab8b64cad7abfb1f316bdbe417 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 01:58:17 +0900 Subject: [PATCH 15/20] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/CategoryFilterSheet.swift | 107 +++++++ .../TodayTab/Sources/TodayView.swift | 288 ------------------ .../TodoInspectorInteractionView.swift | 61 ++++ .../TodayTab/Sources/TodoSection.swift | 148 +++++++++ 4 files changed, 316 insertions(+), 288 deletions(-) create mode 100644 Application/Presentation/TodayTab/Sources/CategoryFilterSheet.swift create mode 100644 Application/Presentation/TodayTab/Sources/TodoInspectorInteractionView.swift create mode 100644 Application/Presentation/TodayTab/Sources/TodoSection.swift diff --git a/Application/Presentation/TodayTab/Sources/CategoryFilterSheet.swift b/Application/Presentation/TodayTab/Sources/CategoryFilterSheet.swift new file mode 100644 index 00000000..7902c5b2 --- /dev/null +++ b/Application/Presentation/TodayTab/Sources/CategoryFilterSheet.swift @@ -0,0 +1,107 @@ +// +// CategoryFilterSheet.swift +// TodayTab +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Core +import PresentationShared + +struct CategoryFilterSheet: View { + let store: StoreOf + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 0) { + CategoryFilterRow( + category: nil, + title: String( + localized: "today_filter_all_categories", + bundle: PresentationResources.bundle + ), + isSelected: store.selectedCategoryID == nil, + action: { store.send(.setCategory(nil)) } + ) + if !store.visibleCategories.isEmpty { + Divider() + } + ForEach( + Array(zip(store.visibleCategories.indices, store.visibleCategories)), + id: \.1.id + ) { index, category in + CategoryFilterRow( + category: category, + title: category.localizedName, + isSelected: store.selectedCategoryID == category.id, + action: { store.send(.setCategory(category.id)) } + ) + if index < store.visibleCategories.count - 1 { + Divider() + } + } + } + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + } + .padding() + } + .background(Color.appBackground.ignoresSafeArea()) + .navigationTitle(String(localized: "todo_category", bundle: PresentationResources.bundle)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + store.send(.binding(.set(\.isCategoryFilterPresented, false))) + } label: { + Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) + } + } + } + } + .presentationDragIndicator(.visible) + } +} + +private struct CategoryFilterRow: View { + let category: TodoCategoryItem? + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button { + action() + } label: { + HStack(spacing: 6) { + Image(systemName: category?.symbolName ?? "tray.2") + .foregroundStyle(category?.color ?? Color.textSecondary) + Text(title) + Spacer() + if isSelected { + Image(systemName: "checkmark") + } + } + .font(.callout) + .foregroundStyle(isSelected ? Color.accent : .textSecondary) + .frame(maxWidth: .infinity) + .padding() + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} + +extension TodayFeature.TodoScope { + var title: String { + switch self { + case .remaining: + return String(localized: "today_filter_remaining", bundle: PresentationResources.bundle) + case .important: + return String(localized: "today_filter_important", bundle: PresentationResources.bundle) + } + } +} diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index dc68f152..79dca053 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -288,294 +288,6 @@ public struct TodayView: View { } } -private struct CategoryFilterSheet: View { - let store: StoreOf - - var body: some View { - NavigationStack { - ScrollView { - LazyVStack(spacing: 0) { - CategoryFilterRow( - category: nil, - title: String( - localized: "today_filter_all_categories", - bundle: PresentationResources.bundle - ), - isSelected: store.selectedCategoryID == nil, - action: { store.send(.setCategory(nil)) } - ) - if !store.visibleCategories.isEmpty { - Divider() - } - ForEach( - Array(zip(store.visibleCategories.indices, store.visibleCategories)), - id: \.1.id - ) { index, category in - CategoryFilterRow( - category: category, - title: category.localizedName, - isSelected: store.selectedCategoryID == category.id, - action: { store.send(.setCategory(category.id)) } - ) - if index < store.visibleCategories.count - 1 { - Divider() - } - } - } - .background { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - } - .padding() - } - .background(Color.appBackground.ignoresSafeArea()) - .navigationTitle(String(localized: "todo_category", bundle: PresentationResources.bundle)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - store.send(.binding(.set(\.isCategoryFilterPresented, false))) - } label: { - Text(String(localized: "profile_done", bundle: PresentationResources.bundle)) - } - } - } - } - .presentationDragIndicator(.visible) - } -} - -private struct CategoryFilterRow: View { - let category: TodoCategoryItem? - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button { - action() - } label: { - HStack(spacing: 6) { - Image(systemName: category?.symbolName ?? "tray.2") - .foregroundStyle(category?.color ?? Color.textSecondary) - Text(title) - Spacer() - if isSelected { - Image(systemName: "checkmark") - } - } - .font(.callout) - .foregroundStyle(isSelected ? Color.accent : .textSecondary) - .frame(maxWidth: .infinity) - .padding() - .contentShape(.rect) - } - .buttonStyle(.plain) - } -} - -private struct TodoSection: View { - let section: TodayFeature.SectionContent - let isNavigationEnabled: Bool - let onSelect: (TodayTodoItem) -> Void - let onInspect: (TodayTodoItem) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Text(section.title) - .font(.title3.bold()) - .foregroundStyle(section.category.accentColor) - Text("\(section.items.count)") - .font(.callout.bold()) - .foregroundStyle(section.category.accentColor) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(section.category.accentColor.opacity(0.1), in: .capsule) - Spacer() - if section.category == .today { - Text(String(localized: "today_pinned_first", bundle: PresentationResources.bundle)) - .font(.callout) - .foregroundStyle(Color.textSecondary) - } - } - - LazyVStack(spacing: 0) { - ForEach(Array(zip(section.items.indices, section.items)), id: \.1.id) { index, item in - TodoRow( - item: item, - isNavigationEnabled: isNavigationEnabled, - onSelect: { onSelect(item) }, - onInspect: { onInspect(item) } - ) - if index < section.items.count - 1 { - Divider() - } - } - } - .background { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - } - } - } -} - -private struct TodoRow: View { - let item: TodayTodoItem - let isNavigationEnabled: Bool - let onSelect: () -> Void - let onInspect: () -> Void - - var body: some View { - Button { - guard isNavigationEnabled else { return } - onSelect() - } label: { - HStack { - VStack(alignment: .leading, spacing: 6) { - Text(item.title) - .font(.headline) - .strikethrough(item.isCompleted) - .foregroundStyle(item.isCompleted ? Color.textSecondary : .primary) - .multilineTextAlignment(.leading) - .lineLimit(2) - if !item.content.isEmpty { - Text(item.content) - .font(.subheadline) - .foregroundStyle(Color.textSecondary) - .lineLimit(1) - .multilineTextAlignment(.leading) - } - todoMetadata - } - Spacer() - Image(systemName: "chevron.right") - .font(.callout.bold()) - .foregroundStyle(Color.textTertiary) - .opacity(isNavigationEnabled ? 1 : 0) - } - .overlay { - TodoInspectorInteractionView(action: onInspect) - } - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - .padding(.vertical, 14) - } - - @ViewBuilder - private var todoMetadata: some View { - let category = TodoCategoryItem(from: item.category) - HStack(spacing: 10) { - Text("#\(item.number)") - .foregroundStyle(Color.accent) - - Image(systemName: category.symbolName) - .foregroundStyle(category.color) - - if let dueDate = item.dueDate { - HStack(spacing: 4) { - Image(systemName: "clock") - Text(dueDateText(dueDate)) - } - .foregroundStyle(dueDateColor(dueDate)) - } - } - .font(.caption.weight(.semibold)) - .lineLimit(1) - } - - private func dueDateText(_ date: Date) -> String { - let calendar = Calendar.autoupdatingCurrent - if calendar.isDateInToday(date) { - return String(localized: "today_section_today", bundle: PresentationResources.bundle) - } - if calendar.isDateInYesterday(date) { - return String(localized: "today_due_yesterday", bundle: PresentationResources.bundle) - } - return date.formatted(.dateTime.month(.abbreviated).day().weekday(.abbreviated)) - } - - private func dueDateColor(_ date: Date) -> Color { - let calendar = Calendar.autoupdatingCurrent - if calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) { - return .danger - } - return .textSecondary - } -} - -private struct TodoInspectorInteractionView: UIViewRepresentable { - let action: () -> Void - - func makeCoordinator() -> TodoInspectorInteractionCoordinator { - TodoInspectorInteractionCoordinator() - } - - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.backgroundColor = .clear - context.coordinator.update(action: action) - context.coordinator.install(on: view) - return view - } - - func updateUIView(_ view: UIView, context: Context) { - context.coordinator.update(action: action) - } -} - -private final class TodoInspectorInteractionCoordinator: NSObject { - private weak var view: UIView? - private var contextMenuInteraction: UIContextMenuInteraction? - private var action: (() -> Void)? - - func update(action: @escaping () -> Void) { - self.action = action - } - - func install(on view: UIView) { - guard self.view !== view else { return } - - if let contextMenuInteraction { - self.view?.removeInteraction(contextMenuInteraction) - } - - let contextMenuInteraction = UIContextMenuInteraction(delegate: self) - view.addInteraction(contextMenuInteraction) - self.view = view - self.contextMenuInteraction = contextMenuInteraction - } -} - -extension TodoInspectorInteractionCoordinator: UIContextMenuInteractionDelegate { - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - action?() - return nil - } -} - -private extension TodayFeature.TodoScope { - var title: String { - switch self { - case .remaining: - return String(localized: "today_filter_remaining", bundle: PresentationResources.bundle) - case .important: - return String(localized: "today_filter_important", bundle: PresentationResources.bundle) - } - } -} - -private extension TodayFeature.SectionCategory { - var accentColor: Color { - self == .overdue ? .danger : .primary - } -} - private extension TodayFeature.TodayAchievement.Status { var title: String { let key = switch self { diff --git a/Application/Presentation/TodayTab/Sources/TodoInspectorInteractionView.swift b/Application/Presentation/TodayTab/Sources/TodoInspectorInteractionView.swift new file mode 100644 index 00000000..9c50eaf1 --- /dev/null +++ b/Application/Presentation/TodayTab/Sources/TodoInspectorInteractionView.swift @@ -0,0 +1,61 @@ +// +// TodoInspectorInteractionView.swift +// TodayTab +// +// Created by opfic on 9/13/26. +// + +import SwiftUI + +struct TodoInspectorInteractionView: UIViewRepresentable { + let action: () -> Void + + func makeCoordinator() -> TodoInspectorInteractionCoordinator { + TodoInspectorInteractionCoordinator() + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + context.coordinator.update(action: action) + context.coordinator.install(on: view) + return view + } + + func updateUIView(_ view: UIView, context: Context) { + context.coordinator.update(action: action) + } +} + +final class TodoInspectorInteractionCoordinator: NSObject { + private weak var view: UIView? + private var contextMenuInteraction: UIContextMenuInteraction? + private var action: (() -> Void)? + + func update(action: @escaping () -> Void) { + self.action = action + } + + func install(on view: UIView) { + guard self.view !== view else { return } + + if let contextMenuInteraction { + self.view?.removeInteraction(contextMenuInteraction) + } + + let contextMenuInteraction = UIContextMenuInteraction(delegate: self) + view.addInteraction(contextMenuInteraction) + self.view = view + self.contextMenuInteraction = contextMenuInteraction + } +} + +extension TodoInspectorInteractionCoordinator: UIContextMenuInteractionDelegate { + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + action?() + return nil + } +} diff --git a/Application/Presentation/TodayTab/Sources/TodoSection.swift b/Application/Presentation/TodayTab/Sources/TodoSection.swift new file mode 100644 index 00000000..81721664 --- /dev/null +++ b/Application/Presentation/TodayTab/Sources/TodoSection.swift @@ -0,0 +1,148 @@ +// +// TodoSection.swift +// TodayTab +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Core +import PresentationShared + +struct TodoSection: View { + let section: TodayFeature.SectionContent + let isNavigationEnabled: Bool + let onSelect: (TodayTodoItem) -> Void + let onInspect: (TodayTodoItem) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text(section.title) + .font(.title3.bold()) + .foregroundStyle(section.category.accentColor) + Text("\(section.items.count)") + .font(.callout.bold()) + .foregroundStyle(section.category.accentColor) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(section.category.accentColor.opacity(0.1), in: .capsule) + Spacer() + if section.category == .today { + Text(String(localized: "today_pinned_first", bundle: PresentationResources.bundle)) + .font(.callout) + .foregroundStyle(Color.textSecondary) + } + } + + LazyVStack(spacing: 0) { + ForEach(Array(zip(section.items.indices, section.items)), id: \.1.id) { index, item in + TodoRow( + item: item, + isNavigationEnabled: isNavigationEnabled, + onSelect: { onSelect(item) }, + onInspect: { onInspect(item) } + ) + if index < section.items.count - 1 { + Divider() + } + } + } + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + } + } + } +} + +private struct TodoRow: View { + let item: TodayTodoItem + let isNavigationEnabled: Bool + let onSelect: () -> Void + let onInspect: () -> Void + + var body: some View { + Button { + guard isNavigationEnabled else { return } + onSelect() + } label: { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .font(.headline) + .strikethrough(item.isCompleted) + .foregroundStyle(item.isCompleted ? Color.textSecondary : .primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + if !item.content.isEmpty { + Text(item.content) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + .multilineTextAlignment(.leading) + } + todoMetadata + } + Spacer() + Image(systemName: "chevron.right") + .font(.callout.bold()) + .foregroundStyle(Color.textTertiary) + .opacity(isNavigationEnabled ? 1 : 0) + } + .overlay { + TodoInspectorInteractionView(action: onInspect) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + @ViewBuilder + private var todoMetadata: some View { + let category = TodoCategoryItem(from: item.category) + HStack(spacing: 10) { + Text("#\(item.number)") + .foregroundStyle(Color.accent) + + Image(systemName: category.symbolName) + .foregroundStyle(category.color) + + if let dueDate = item.dueDate { + HStack(spacing: 4) { + Image(systemName: "clock") + Text(dueDateText(dueDate)) + } + .foregroundStyle(dueDateColor(dueDate)) + } + } + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + + private func dueDateText(_ date: Date) -> String { + let calendar = Calendar.autoupdatingCurrent + if calendar.isDateInToday(date) { + return String(localized: "today_section_today", bundle: PresentationResources.bundle) + } + if calendar.isDateInYesterday(date) { + return String(localized: "today_due_yesterday", bundle: PresentationResources.bundle) + } + return date.formatted(.dateTime.month(.abbreviated).day().weekday(.abbreviated)) + } + + private func dueDateColor(_ date: Date) -> Color { + let calendar = Calendar.autoupdatingCurrent + if calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) { + return .danger + } + return .textSecondary + } +} + +private extension TodayFeature.SectionCategory { + var accentColor: Color { + self == .overdue ? .danger : .primary + } +} From 05544dad0d80ad02d5b3171119ed280c01200772 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 02:12:08 +0900 Subject: [PATCH 16/20] =?UTF-8?q?ui:=20=ED=88=B4=EB=B0=94=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Presentation/TodayTab/Sources/TodayView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 79dca053..87ced6ff 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -61,6 +61,7 @@ public struct TodayView: View { .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground.ignoresSafeArea()) .refreshable { await store.send(.refresh).finish() } + .toolbarVisibility(.hidden, for: .navigationBar) .navigationDestination(for: TodayRoute.self, destination: destination) .sheet(isPresented: $store.isCategoryFilterPresented) { CategoryFilterSheet(store: store) From 55221802b112fee48c0d10be475dd8df07b83b6c Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 10:17:44 +0900 Subject: [PATCH 17/20] =?UTF-8?q?ui:=20TodoEditorView=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=20Todo=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EB=8A=94=20=EB=B7=B0=EB=A5=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=ED=84=B0=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 --- .../Sources/Todo/Editor/TodoEditorView.swift | 288 +---------------- .../Todo/Editor/TodoPropertiesView.swift | 303 ++++++++++++++++++ .../TodayTab/Sources/TodayView.swift | 7 +- 3 files changed, 314 insertions(+), 284 deletions(-) create mode 100644 Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoPropertiesView.swift diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index b58b7abf..f48a4478 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -84,9 +84,10 @@ public struct TodoEditorView: View { .toolbarVisibility(.hidden, for: .navigationBar) .prominentAlert(store, state: \.alert, action: \.alert) .inspector(isPresented: $store.isInspectorPresented) { + Group { switch store.inspectorContent { case .options: - InspectorView( + TodoPropertiesView( store: store, showsEditorActions: movesActionsToInspector, onSubmit: submit @@ -127,6 +128,8 @@ public struct TodoEditorView: View { } } } + } + .inspectorColumnWidth(min: 320, ideal: 420, max: 520) } } } @@ -196,7 +199,7 @@ private struct ToolBar: View { } } -private struct EditorToolbarActions: View { +struct EditorToolbarActions: View { let store: StoreOf let onSubmit: () -> Void @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( @@ -381,287 +384,6 @@ private struct ContentView: View { } } -private struct InspectorView: View { - @Bindable var store: StoreOf - @FocusState private var isTagFieldFocused: Bool - @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( - forTextStyle: .title1, - compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) - ).lineHeight - var showsEditorActions = false - var onSubmit: () -> Void = {} - let onClose: () -> Void - private let calendar = Calendar.current - - var body: some View { - ZStack { - Color.appBackground.ignoresSafeArea() - VStack(spacing: 8) { - toolBar - content - } - } - } - - private var toolBar: some View { - HStack { - Spacer() - if showsEditorActions { - EditorToolbarActions(store: store, onSubmit: onSubmit) - } else { - Button { - onClose() - } label: { - Image(systemName: "checkmark") - .frame(width: iconSize, height: iconSize) - .foregroundStyle(Color.primary) - } - .font(.title) - .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) - } - } - .padding(.horizontal) - .padding(.top, 12) - .overlay { - if !showsEditorActions { - Text(String(localized: "todo_details", bundle: PresentationResources.bundle)) - .font(.title3.bold()) - } - } - } - - private var content: some View { - ScrollView { - LazyVStack(spacing: 24) { - VStack(spacing: 0) { - HStack(spacing: 12) { - optionIcon("tag.fill", color: Color.accent) - Spacer() - Picker( - String(localized: "todo_category", bundle: PresentationResources.bundle), - selection: $store.selectedCategoryID - ) { - ForEach(store.categories, id: \.id) { item in - Text(item.localizedName) - .tag(item.id) - } - } - .pickerStyle(.menu) - .tint(Color.accent) - } - .padding(.vertical, 12) - - Divider().overlay(Color.border) - - Toggle(isOn: $store.isCompleted) { - HStack(spacing: 12) { - optionIcon("circle", color: Color.textSecondary) - Text(String(localized: "todo_completed", bundle: PresentationResources.bundle)) - } - } - .tint(Color.accent) - .padding(.vertical, 12) - - Divider().overlay(Color.border) - - Toggle(isOn: $store.isPinned) { - HStack(spacing: 12) { - optionIcon("star.fill", color: Color.warning) - Text(String(localized: "todo_pinned", bundle: PresentationResources.bundle)) - } - } - .tint(Color.accent) - .padding(.vertical, 12) - - Divider().overlay(Color.border) - - dueDateControl - .padding(.vertical, 12) - } - .padding(.horizontal, 20) - .padding(.vertical, 8) - .background { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) - } - - VStack(alignment: .leading, spacing: 12) { - Text(String(localized: "todo_tags", bundle: PresentationResources.bundle)) - .font(.headline) - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - TextField( - String(localized: "todo_add", bundle: PresentationResources.bundle), - text: $store.tagText - ) - .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) - .textInputAutocapitalization(.never) - .focused($isTagFieldFocused) - .onSubmit { submitTag() } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: 12) - .fill(Color.surfaceSecondary) - .strokeBorder(Color.border, lineWidth: 2) - } - .tint(Color.accent) - - Button { - submitTag() - } label: { - Image(systemName: "plus") - .font(.title2.weight(.semibold)) - .foregroundStyle(canSubmitTag ? Color.surface : Color.textSecondary) - .padding(12) - .background( - canSubmitTag ? Color.accent : Color.surfaceSecondary, - in: Circle() - ) - } - .buttonStyle(.plain) - .disabled(!canSubmitTag) - } - - if store.tags.isEmpty { - Text(String(localized: "todo_no_tags", bundle: PresentationResources.bundle)) - .foregroundStyle(.secondary) - .padding(.vertical, 4) - } else { - TagList( - store.tags, - isEditing: true, - action: { store.send(.removeTag($0)) } - ) - } - Text(String( - localized: "todo_tag_duplicate_hint", - bundle: PresentationResources.bundle - )) - } - .padding(20) - .background { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) - } - } - } - .padding(.horizontal, 20) - .padding(.bottom, 20) - } - .contentMargins(.top, 16, for: .scrollContent) - } - - private func optionIcon(_ name: String, color: Color) -> some View { - Image(systemName: name) - .font(.title3) - .foregroundStyle(color) - .frame(width: 44, height: 44) - .background(color.opacity(0.08), in: Circle()) - } - - private var dueDateControl: some View { - DueDatePicker(selection: $store.selectedDueDate) { - HStack(spacing: 12) { - optionIcon("calendar", color: Color.textSecondary) - Text(String(localized: "todo_due_date", bundle: PresentationResources.bundle)) - .foregroundStyle(.primary) - Spacer() - if let dueDate = store.dueDate { - Tag(dueDateText(for: dueDate), isEditing: true) { - store.send(.binding(.set(\.dueDate, nil))) - } - .padding(.vertical, -4) - } else { - Text(String(localized: "todo_none", bundle: PresentationResources.bundle)) - .foregroundStyle(.secondary) - } - } - } - } - - private func close() { - isTagFieldFocused = false - onClose() - } - - private func submitTag() { - guard canSubmitTag else { return } - - let tagText = normalizedTagText - store.send(.addTag(tagText)) - store.send(.binding(.set(\.tagText, ""))) - } - - private var normalizedTagText: String { - store.tagText.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var canSubmitTag: Bool { - !normalizedTagText.isEmpty && !store.tags.contains(normalizedTagText) - } - - private func dueDateText(for dueDate: Date) -> String { - let currentYear = calendar.component(.year, from: Date()) - let dueDateYear = calendar.component(.year, from: dueDate) - - if currentYear == dueDateYear { - return dueDate.formatted( - .dateTime.month(.defaultDigits).day(.defaultDigits) - ) - } - - return dueDate.formatted( - .dateTime.year(.twoDigits).month(.defaultDigits).day(.defaultDigits) - ) - } -} - -private struct DueDatePicker: View { - @Environment(\.isTabContentActive) private var isTabContentActive - @Environment(\.safeAreaInsets) private var safeAreaInsets - @State private var isPresented: Bool = false - @State private var height: CGFloat = .pi - @Binding var dueDate: Date - @ViewBuilder private var content: () -> Content - - init( - selection dueDate: Binding, - @ViewBuilder content: @escaping () -> Content - ) { - self._dueDate = dueDate - self.content = content - } - - var body: some View { - Button { - isPresented.toggle() - } label: { - content() - } - .sheet(isPresented: $isPresented.activePresentation(when: isTabContentActive)) { - DatePicker( - "", - selection: $dueDate, - displayedComponents: .date - ) - .labelsHidden() - .datePickerStyle(.graphical) - .presentationDragIndicator(.visible) - .presentationDetents([.height(height)]) - .background { - GeometryReader { geometry in - Color.clear.onAppear { - height = geometry.size.height + safeAreaInsets.bottom + safeAreaInsets.top - } - } - } - } - } -} - private enum Field: Hashable { case title, content } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoPropertiesView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoPropertiesView.swift new file mode 100644 index 00000000..e9cce042 --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoPropertiesView.swift @@ -0,0 +1,303 @@ +// +// TodoPropertiesView.swift +// PresentationShared +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import ComposableArchitecture +import Core + +public struct TodoPropertiesView: View { + @Bindable var store: StoreOf + @FocusState private var isTagFieldFocused: Bool + @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( + forTextStyle: .title1, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).lineHeight + var showsEditorActions = false + var onSubmit: () -> Void = {} + let onClose: () -> Void + private let calendar = Calendar.current + + public init( + store: StoreOf, + showsEditorActions: Bool = false, + onSubmit: @escaping () -> Void = {}, + onClose: @escaping () -> Void + ) { + self.store = store + self.showsEditorActions = showsEditorActions + self.onSubmit = onSubmit + self.onClose = onClose + } + + public var body: some View { + ZStack { + Color.appBackground.ignoresSafeArea() + VStack(spacing: 8) { + toolBar + content + } + } + } + + private var toolBar: some View { + HStack { + Spacer() + if showsEditorActions { + EditorToolbarActions(store: store, onSubmit: onSubmit) + } else { + Button { + onClose() + } label: { + Image(systemName: "checkmark") + .frame(width: iconSize, height: iconSize) + .foregroundStyle(Color.primary) + } + .font(.title) + .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) + } + } + .padding(.horizontal) + .padding(.top, 12) + .overlay { + if !showsEditorActions { + Text(String(localized: "todo_details", bundle: PresentationResources.bundle)) + .font(.title3.bold()) + } + } + } + + private var content: some View { + ScrollView { + LazyVStack(spacing: 24) { + VStack(spacing: 0) { + HStack(spacing: 12) { + optionIcon("tag.fill", color: Color.accent) + Spacer() + Picker( + String(localized: "todo_category", bundle: PresentationResources.bundle), + selection: $store.selectedCategoryID + ) { + ForEach(store.categories, id: \.id) { item in + Text(item.localizedName) + .tag(item.id) + } + } + .pickerStyle(.menu) + .tint(Color.accent) + } + .padding(.vertical, 12) + + Divider().overlay(Color.border) + + Toggle(isOn: $store.isCompleted) { + HStack(spacing: 12) { + optionIcon("circle", color: Color.textSecondary) + Text(String(localized: "todo_completed", bundle: PresentationResources.bundle)) + } + } + .tint(Color.accent) + .padding(.vertical, 12) + + Divider().overlay(Color.border) + + Toggle(isOn: $store.isPinned) { + HStack(spacing: 12) { + optionIcon("star.fill", color: Color.warning) + Text(String(localized: "todo_pinned", bundle: PresentationResources.bundle)) + } + } + .tint(Color.accent) + .padding(.vertical, 12) + + Divider().overlay(Color.border) + + dueDateControl + .padding(.vertical, 12) + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "todo_tags", bundle: PresentationResources.bundle)) + .font(.headline) + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + TextField( + String(localized: "todo_add", bundle: PresentationResources.bundle), + text: $store.tagText + ) + .frame(height: UIFont.preferredFont(forTextStyle: .title2).lineHeight) + .textInputAutocapitalization(.never) + .focused($isTagFieldFocused) + .onSubmit { submitTag() } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 12) + .fill(Color.surfaceSecondary) + .strokeBorder(Color.border, lineWidth: 2) + } + .tint(Color.accent) + + Button { + submitTag() + } label: { + Image(systemName: "plus") + .font(.title2.weight(.semibold)) + .foregroundStyle(canSubmitTag ? Color.surface : Color.textSecondary) + .padding(12) + .background( + canSubmitTag ? Color.accent : Color.surfaceSecondary, + in: Circle() + ) + } + .buttonStyle(.plain) + .disabled(!canSubmitTag) + } + + if store.tags.isEmpty { + Text(String(localized: "todo_no_tags", bundle: PresentationResources.bundle)) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } else { + TagList( + store.tags, + isEditing: true, + action: { store.send(.removeTag($0)) } + ) + } + Text(String( + localized: "todo_tag_duplicate_hint", + bundle: PresentationResources.bundle + )) + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 2) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .contentMargins(.top, 16, for: .scrollContent) + } + + private func optionIcon(_ name: String, color: Color) -> some View { + Image(systemName: name) + .font(.title3) + .foregroundStyle(color) + .frame(width: 44, height: 44) + .background(color.opacity(0.08), in: Circle()) + } + + private var dueDateControl: some View { + DueDatePicker(selection: $store.selectedDueDate) { + HStack(spacing: 12) { + optionIcon("calendar", color: Color.textSecondary) + Text(String(localized: "todo_due_date", bundle: PresentationResources.bundle)) + .foregroundStyle(.primary) + Spacer() + if let dueDate = store.dueDate { + Tag(dueDateText(for: dueDate), isEditing: true) { + store.send(.binding(.set(\.dueDate, nil))) + } + .padding(.vertical, -4) + } else { + Text(String(localized: "todo_none", bundle: PresentationResources.bundle)) + .foregroundStyle(.secondary) + } + } + } + } + + private func close() { + isTagFieldFocused = false + onClose() + } + + private func submitTag() { + guard canSubmitTag else { return } + + let tagText = normalizedTagText + store.send(.addTag(tagText)) + store.send(.binding(.set(\.tagText, ""))) + } + + private var normalizedTagText: String { + store.tagText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmitTag: Bool { + !normalizedTagText.isEmpty && !store.tags.contains(normalizedTagText) + } + + private func dueDateText(for dueDate: Date) -> String { + let currentYear = calendar.component(.year, from: Date()) + let dueDateYear = calendar.component(.year, from: dueDate) + + if currentYear == dueDateYear { + return dueDate.formatted( + .dateTime.month(.defaultDigits).day(.defaultDigits) + ) + } + + return dueDate.formatted( + .dateTime.year(.twoDigits).month(.defaultDigits).day(.defaultDigits) + ) + } +} + +private struct DueDatePicker: View { + @Environment(\.isTabContentActive) private var isTabContentActive + @Environment(\.safeAreaInsets) private var safeAreaInsets + @State private var isPresented: Bool = false + @State private var height: CGFloat = .pi + @Binding var dueDate: Date + @ViewBuilder private var content: () -> Content + + init( + selection dueDate: Binding, + @ViewBuilder content: @escaping () -> Content + ) { + self._dueDate = dueDate + self.content = content + } + + var body: some View { + Button { + isPresented.toggle() + } label: { + content() + } + .sheet(isPresented: $isPresented.activePresentation(when: isTabContentActive)) { + DatePicker( + "", + selection: $dueDate, + displayedComponents: .date + ) + .labelsHidden() + .datePickerStyle(.graphical) + .presentationDragIndicator(.visible) + .presentationDetents([.height(height)]) + .background { + GeometryReader { geometry in + Color.clear.onAppear { + height = geometry.size.height + safeAreaInsets.bottom + safeAreaInsets.top + } + } + } + } + } +} diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 87ced6ff..fa0f6f10 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -40,6 +40,7 @@ public struct TodayView: View { } else { ForEach(store.sections) { section in TodoSection( + section: section, isNavigationEnabled: !store.isTodoInspectorPresented, onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, @@ -222,11 +223,15 @@ public struct TodayView: View { @ViewBuilder private var todoInspector: some View { if let editorStore = store.scope(state: \.todoEditor, action: \.todoEditor) { - TodoEditorView( + TodoPropertiesView( store: editorStore, + showsEditorActions: true, + onSubmit: { editorStore.send(.upsertTodo) }, onClose: { store.send(.dismissTodoInspector) } ) .inspectorColumnWidth(min: 320, ideal: 420, max: 520) + .onAppear { editorStore.send(.onAppear) } + .prominentAlert(editorStore, state: \.alert, action: \.alert) } } From 879aa24ae3b76533fdc6b1526601695e75e194e2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 11:27:00 +0900 Subject: [PATCH 18/20] =?UTF-8?q?ui:=20=ED=83=80=EC=9D=B4=ED=8B=80?= =?UTF-8?q?=EC=9D=84=20=EC=8A=A4=ED=8B=B0=ED=82=A4=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProfileTab/Sources/Profile/ProfileView.swift | 2 +- .../Presentation/TodayTab/Sources/TodayView.swift | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index cd2c5ea6..076c1188 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -49,9 +49,9 @@ public struct ProfileView: View { } .refreshable { await store.send(.refresh).finish() } .toolbarVisibility(.hidden, for: .navigationBar) - .frame(maxWidth: .infinity) .background(Color.appBackground) .navigationDestination(for: ProfileRoute.self, destination: destinationView) + .border(Color.blue) } .onChange(of: isSelected, initial: true) { _, isSelected in if isSelected { diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index fa0f6f10..2f919b59 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -40,7 +40,6 @@ public struct TodayView: View { } else { ForEach(store.sections) { section in TodoSection( - section: section, isNavigationEnabled: !store.isTodoInspectorPresented, onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, @@ -50,7 +49,8 @@ public struct TodayView: View { .padding(.bottom, 12) } } header: { - VStack { + VStack(alignment: .leading) { + topBar achievementCard filterBar } @@ -59,8 +59,7 @@ public struct TodayView: View { } .padding(.horizontal) } - .safeAreaInset(edge: .top, spacing: 0) { topBar } - .background(Color.appBackground.ignoresSafeArea()) + .background(Color.appBackground) .refreshable { await store.send(.refresh).finish() } .toolbarVisibility(.hidden, for: .navigationBar) .navigationDestination(for: TodayRoute.self, destination: destination) @@ -96,11 +95,7 @@ public struct TodayView: View { private var topBar: some View { Text(String(localized: "nav_today", bundle: PresentationResources.bundle)) .font(.largeTitle.bold()) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.top, 12) .padding(.bottom, 8) - .background(Color.appBackground, ignoresSafeAreaEdges: .top) } private var achievementCard: some View { From fae7b11754f5e207bc5e47fca0aee58695dce1d8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 12:18:34 +0900 Subject: [PATCH 19/20] =?UTF-8?q?ui:=20=EB=94=94=EB=B2=84=EA=B9=85?= =?UTF-8?q?=EC=9A=A9=20=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/ProfileTab/Sources/Profile/ProfileView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift index 076c1188..b827e2c6 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -51,7 +51,6 @@ public struct ProfileView: View { .toolbarVisibility(.hidden, for: .navigationBar) .background(Color.appBackground) .navigationDestination(for: ProfileRoute.self, destination: destinationView) - .border(Color.blue) } .onChange(of: isSelected, initial: true) { _, isSelected in if isSelected { From eb2c7fc932a9dd7a688eb612ea4721028a554b3a Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 12:30:59 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20writable=20root=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=ED=99=88=20=EA=B2=BD=EB=A1=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/config.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.codex/config.toml b/.codex/config.toml index b49169d5..7a695091 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -2,7 +2,7 @@ sandbox_mode = "workspace-write" [sandbox_workspace_write] writable_roots = [ - "/Users/opfic/.local/state/tuist", - "/Users/opfic/.cache/tuist", - "/Users/opfic/Library/Developer/Xcode/DerivedData", + "~/.local/state/tuist", + "~/.cache/tuist", + "~/Library/Developer/Xcode/DerivedData", ]