diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..7a695091 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,8 @@ +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + "~/.local/state/tuist", + "~/.cache/tuist", + "~/Library/Developer/Xcode/DerivedData", +] 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/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 95% rename from Application/Presentation/HomeTab/Sources/Home/Search/SearchView.swift rename to Application/Presentation/HomeTab/Sources/Search/SearchView.swift index c39eaa06..520072ad 100644 --- a/Application/Presentation/HomeTab/Sources/Home/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) @@ -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) @@ -234,7 +234,6 @@ private struct SearchResults: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } if store.shouldShowMoreTodos { Button { @@ -244,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) @@ -257,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) @@ -355,7 +352,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/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/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 29323199..c8536dbf 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,13 +2032,13 @@ } } }, - "today_complete_action" : { + "today_achievement_status_completed" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Complete" + "value" : "Completed" } }, "ko" : { @@ -2049,155 +2049,155 @@ } } }, - "today_due_overdue" : { + "today_achievement_status_empty" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Overdue" + "value" : "No tasks" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기한 지남" + "value" : "예정 없음" } } } }, - "today_due_visibility_all" : { + "today_achievement_status_in_progress" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "All" + "value" : "In Progress" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "전체" + "value" : "진행 중" } } } }, - "today_due_visibility_label" : { + "today_achievement_status_loading" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "View Range" + "value" : "Loading" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "보기 범위" + "value" : "불러오는 중" } } } }, - "today_due_visibility_with_due" : { + "today_achievement_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "With Due Date Only" + "value" : "Today's Progress" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기한 있는 Todo만" + "value" : "Todo 완료" } } } }, - "today_due_visibility_without_due" : { + "today_complete_action" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Without Due Date Only" + "value" : "Complete" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기한 없는 Todo만" + "value" : "완료" } } } }, - "today_empty_all_message" : { + "today_due_overdue" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Incomplete work will appear here in priority order." + "value" : "Overdue" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있어요." + "value" : "기한 지남" } } } }, - "today_empty_all_title" : { + "today_due_yesterday" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "There are no remaining todos." + "value" : "Due Yesterday" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "남아 있는 Todo가 없어요." + "value" : "어제까지" } } } }, - "today_empty_due_soon_message" : { + "today_empty_all_message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Todos due soon will appear here first." + "value" : "Incomplete work will appear here in priority order." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있어요." + "value" : "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있어요." } } } }, - "today_empty_due_soon_title" : { + "today_empty_all_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "There is no schedule within 7 days." + "value" : "There are no remaining todos." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "7일 내 일정이 없어요." + "value" : "남아 있는 Todo가 없어요." } } } @@ -2208,13 +2208,13 @@ "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" : "필터를 조정하거나 남은 일 보기로 돌아가세요." } } } @@ -2236,172 +2236,104 @@ } } }, - "today_empty_focused_message" : { + "today_filter_all_categories" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Pinned todos will appear here right away." + "value" : "All Categories" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있어요." + "value" : "모든 카테고리" } } } }, - "today_empty_focused_title" : { + "today_pinned_first" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "There is nothing to focus on." + "value" : "Pinned First" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "집중할 일이 없어요." + "value" : "중요 항목 우선" } } } }, - "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" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pinned Only" - } - }, - "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만 표시돼요." - } - } - } - }, - "today_section_due_soon_format" : { + "today_section_later" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Due Within %1$lld Days" + "value" : "Later" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld일 내 일정" + "value" : "나중 일정" } } } }, - "today_section_focused" : { + "today_section_overdue" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Focus" + "value" : "Overdue Tasks" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "집중할 일" + "value" : "기한이 지난 Todo" } } } }, - "today_section_later" : { + "today_section_today" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Later" + "value" : "Due Today" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "나중 일정" + "value" : "오늘까지" } } } }, - "today_section_overdue" : { + "today_section_upcoming" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Overdue" + "value" : "Upcoming" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "지난 마감" + "value" : "다가오는 일정" } } } @@ -2418,7 +2350,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "일정 미정" + "value" : "기한 없음" } } } @@ -2440,7 +2372,7 @@ } } }, - "today_summary_all" : { + "today_filter_remaining" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -2457,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" : { @@ -2486,24 +2401,7 @@ "ko" : { "stringUnit" : { "state" : "translated", - "value" : "집중" - } - } - } - }, - "today_summary_overdue" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Overdue" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "지연" + "value" : "중요" } } } 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/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/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..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) } } } @@ -182,7 +185,7 @@ private struct ToolBar: View { } } .font(.title) - .adaptiveButtonStyle(shape: .circle) + .adaptiveButtonStyle(shape: .circle, glassEffect: .enabled) Spacer() Text(store.navigationTitle) .font(.title3.bold()) @@ -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( @@ -214,11 +217,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 +236,7 @@ private struct EditorToolbarActions: View { } } .font(.title) - .adaptiveButtonStyle(shape: .circle, color: Color.surface) + .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) .disabled(!store.isReadyToSubmit) } } @@ -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) - } - } - .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/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..b827e2c6 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/ProfileView.swift @@ -49,7 +49,6 @@ 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) } @@ -409,7 +408,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/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/Today/TodayDependencyPreparation.swift b/Application/Presentation/TodayTab/Sources/Today/TodayDependencyPreparation.swift deleted file mode 100644 index cc76e25c..00000000 --- a/Application/Presentation/TodayTab/Sources/Today/TodayDependencyPreparation.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// TodayDependencyPreparation.swift -// TodayTab -// -// Created by opfic on 9/7/26. -// - -import Domain -import PresentationShared - -public enum TodayDependencyPreparation { - public static func prepare( - _ dependencies: inout DependencyValues, - fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase, - fetchTodosUseCase: FetchTodosUseCase, - updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase - ) { - 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.") - } -} diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayFeature+State.swift b/Application/Presentation/TodayTab/Sources/Today/TodayFeature+State.swift deleted file mode 100644 index 5b4b9e9e..00000000 --- a/Application/Presentation/TodayTab/Sources/Today/TodayFeature+State.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// TodayFeature+State.swift -// TodayTab -// -// Created by opfic on 6/14/26. -// - -import Core -import Foundation - -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 - ) -> [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) - } - } - - 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( - byAdding: .day, - value: TodayFeature.upcomingWindowDays, - to: startOfToday - ) else { - return TodayFeature.SectionCollection( - focused: items.filter(\.isPinned), - unscheduled: items.filter { !$0.isPinned && $0.dueDate == nil } - ) - } - - var collection = TodayFeature.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 { - collection.overdue.append(item) - } else if dueDay <= windowEnd { - collection.dueSoon.append(item) - } else { - collection.later.append(item) - } - } - - 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, - items: [TodayTodoItem] - ) -> [SectionContent] { - guard !items.isEmpty else { return [] } - return [SectionContent(category: category, title: title, items: items)] - } -} diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayFeature.swift b/Application/Presentation/TodayTab/Sources/Today/TodayFeature.swift deleted file mode 100644 index f0a9d024..00000000 --- a/Application/Presentation/TodayTab/Sources/Today/TodayFeature.swift +++ /dev/null @@ -1,362 +0,0 @@ -// -// TodayFeature.swift -// TodayTab -// -// Created by opfic on 6/14/26. -// - -import Core -import Domain -import Foundation -import PresentationShared - -@Reducer -struct TodayFeature { - enum SectionScope: Hashable, CaseIterable { - case all - case focused - case overdue - case dueSoon - } - - enum SectionCategory: Hashable { - case later - case unscheduled - case focused - case overdue - case dueSoon - } - - struct SectionContent: Identifiable, Equatable { - var id: SectionCategory { category } - let category: SectionCategory - let title: String - let items: [TodayTodoItem] - } - - struct SectionCollection { - var focused: [TodayTodoItem] = [] - var overdue: [TodayTodoItem] = [] - var dueSoon: [TodayTodoItem] = [] - var later: [TodayTodoItem] = [] - 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 setSectionScope(SectionScope) - case resetDisplayOptions - case completeTodo(TodayTodoItem) - case togglePinned(TodayTodoItem) - case store(StoreAction) - case loading(LoadingFeature.Action) - - enum StoreAction: Equatable { - case setAlert - case setTodos([TodayTodoItem]) - case updateTodo(TodayTodoItem) - case removeTodo(String) - } - } - - @Dependency(\.todayFetchTodosUseCase) var fetchTodosUseCase - @Dependency(\.fetchTodoByIdUseCase) var fetchTodoByIdUseCase - @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase - @Dependency(\.updateTodayDisplayOptionsUseCase) var updateTodayDisplayOptionsUseCase - @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase - - static let pageSize = 20 - static let upcomingWindowDays = 7 - - var body: some ReducerOf { - Scope(state: \.loading, action: \.loading) { - LoadingFeature() - } - BindingReducer() - Reduce { state, action in - switch action { - case .alert: - break - case .binding(\.displayOptions.dueDateVisibility), - .binding(\.displayOptions.focusVisibility), - .binding(\.displayOptions.isFocusedOnly): - return updateDisplayOptionsEffect(state.displayOptions) - case .binding: - break - case .refresh: - return fetchTodosEffect(showsIndicator: false) - case .fetchData: - return fetchTodosEffect() - 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 .completeTodo(let item): - return completeTodoEffect(item) - case .togglePinned(let item): - return togglePinnedEffect(item) - case .store(.setAlert): - state.alert = Self.alertState() - case .store(.setTodos(let todos)): - state.todos = todos - case .store(.updateTodo(let item)): - 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 } - case .loading: - break - } - - return .none - } - .ifLet(\.$alert, action: \.alert) - } -} - -extension DependencyValues { - var todayFetchTodosUseCase: FetchTodosUseCase { - 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 { - static var liveValue: FetchTodosUseCase { - preconditionFailure("FetchTodosUseCase must be provided.") - } - - static var testValue: FetchTodosUseCase { - liveValue - } -} - -private enum UpdateTodayDisplayOptionsUseCaseKey: DependencyKey { - static var liveValue: UpdateTodayDisplayOptionsUseCase { - preconditionFailure("UpdateTodayDisplayOptionsUseCase must be provided.") - } - - static var testValue: UpdateTodayDisplayOptionsUseCase { - liveValue - } -} - -private extension TodayFeature { - func fetchTodosEffect(showsIndicator: Bool = true) -> Effect { - .run { [fetchTodosUseCase] send in - if showsIndicator { - await send(.loading(.begin(target: .default, mode: .delayed))) - } - do { - async let todosWithDueDatePage = fetchTodosUseCase.execute( - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: .withDueDate, - sortTarget: .dueDate, - sortOrder: .oldest, - pageSize: Self.pageSize, - fetchAllPages: true - ), - cursor: nil - ) - async let todosWithoutDueDatePage = fetchTodosUseCase.execute( - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: .withoutDueDate, - sortTarget: .updatedAt, - sortOrder: .latest, - pageSize: Self.pageSize, - fetchAllPages: true - ), - 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))) - if showsIndicator { - await send(.loading(.end(target: .default, mode: .delayed))) - } - } catch { - if showsIndicator { - await send(.loading(.end(target: .default, mode: .delayed))) - } - await send(.store(.setAlert)) - } - } - } - - func updateDisplayOptionsEffect(_ options: TodayDisplayOptions) -> Effect { - .run { [updateTodayDisplayOptionsUseCase] _ in - updateTodayDisplayOptionsUseCase.execute(options) - } - } - - func completeTodoEffect(_ item: TodayTodoItem) -> Effect { - .run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase] 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))) - await send(.loading(.end(target: .default, mode: .delayed))) - } catch { - await send(.loading(.end(target: .default, mode: .delayed))) - await send(.store(.setAlert)) - } - } - } - - func togglePinnedEffect(_ item: TodayTodoItem) -> Effect { - .run { [fetchTodoByIdUseCase, upsertTodoUseCase] 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() - 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)) - } actions: { - ButtonState(role: .cancel) { - TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) - } - } message: { - TextState(String(localized: "common_error_message", bundle: PresentationResources.bundle)) - } - } -} diff --git a/Application/Presentation/TodayTab/Sources/Today/TodayView.swift b/Application/Presentation/TodayTab/Sources/Today/TodayView.swift deleted file mode 100644 index 2c309d6d..00000000 --- a/Application/Presentation/TodayTab/Sources/Today/TodayView.swift +++ /dev/null @@ -1,380 +0,0 @@ -// -// TodayView.swift -// TodayTab -// -// Created by opfic on 3/6/26. -// - -import SwiftUI -import Core -import Domain -import PresentationShared - -public struct TodayView: View { - @State private var path = [TodayRoute]() - @State private var store: StoreOf - private let isSelected: Bool - private let windowEvent: TodoEditorWindowEvent - - public init( - isSelected: Bool, - windowEvent: TodoEditorWindowEvent - ) { - @Dependency(\.todayFetchDisplayOptionsUseCase) var fetchDisplayOptionsUseCase - self._store = State(initialValue: Store( - initialState: TodayFeature.State( - displayOptions: fetchDisplayOptionsUseCase.execute() - ) - ) { - TodayFeature() - }) - self.isSelected = isSelected - self.windowEvent = windowEvent - } - - public var body: some View { - 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() - } - } - } - - private func destinationView(_ route: TodayRoute) -> some View { - switch route { - case .todo(let item): - TodoDetailView( - store: Store( - initialState: TodoDetailFeature.State(todoId: item.id, showEditButton: true) - ) { - TodoDetailFeature() - }, - windowEvent: windowEvent - ) - .id(item.id) - } - } - - private var summarySection: some View { - Section { - ScrollView(.horizontal) { - 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 - ) - } - .buttonStyle(.plain) - } - } - } - .scrollIndicators(.never) - .contentMargins(.horizontal, 16) - } - .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) - } - - @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) - } - } - - Toggle( - String(localized: "today_pinned_only", bundle: PresentationResources.bundle), - isOn: $store.displayOptions.isFocusedOnly - ) - .tint(.orange) - - if store.displayOptions.focusVisibility == .focusedOnly { - Text(String(localized: "today_pinned_only_description", bundle: PresentationResources.bundle)) - .font(.caption) - } - } 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) - } - } - - @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()) - } - } - } - - @ViewBuilder - private func todoRow(_ item: TodayTodoItem) -> some View { - NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { - TodayTodoRow(item: item) - } - .todoDetailPreview(todoId: item.id) - } - - 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: - return EmptyStateContent( - title: String(localized: "today_empty_overdue_title", bundle: PresentationResources.bundle), - message: String(localized: "today_empty_overdue_message", bundle: PresentationResources.bundle) - ) - case .dueSoon: - return EmptyStateContent( - title: String(localized: "today_empty_due_soon_title", bundle: PresentationResources.bundle), - message: String(localized: "today_empty_due_soon_message", bundle: PresentationResources.bundle) - ) - } - } - - private struct EmptyStateContent { - let title: String - let message: String - } -} - -private extension TodayDisplayOptions.DueDateVisibility { - 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) - } - } - - var accentColor: Color { - switch self { - case .all: - return .blue - case .focused: - return .orange - case .overdue: - return .red - 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)) - } - .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) - ) - } - - 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 - } -} diff --git a/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift b/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift new file mode 100644 index 00000000..9023a327 --- /dev/null +++ b/Application/Presentation/TodayTab/Sources/TodayDependencyPreparation.swift @@ -0,0 +1,20 @@ +// +// TodayDependencyPreparation.swift +// TodayTab +// +// Created by opfic on 9/7/26. +// + +import Domain +import PresentationShared + +public enum TodayDependencyPreparation { + public static func prepare( + _ dependencies: inout DependencyValues, + fetchTodosUseCase: FetchTodosUseCase, + fetchCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase + ) { + dependencies.todayFetchTodosUseCase = fetchTodosUseCase + dependencies.fetchTodoCategoryPreferencesUseCase = fetchCategoryPreferencesUseCase + } +} diff --git a/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift new file mode 100644 index 00000000..b1313246 --- /dev/null +++ b/Application/Presentation/TodayTab/Sources/TodayFeature+State.swift @@ -0,0 +1,257 @@ +// +// TodayFeature+State.swift +// TodayTab +// +// Created by opfic on 6/14/26. +// + +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 categories = [TodoCategoryItem]() + 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 + var isCompletedTodayDataLoaded = false + + init(now: Date = Date()) { + self.todayInterval = TodayFeature.dayInterval(containing: now) + } + + var isLoading: Bool { + loading.isLoading + } + + var isTodayDataLoaded: Bool { + 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( + incompleteTodos: todos, + completedTodos: completedTodayTodos, + interval: todayInterval + ) + } + + var todayAchievement: TodayAchievement? { + guard isTodayDataLoaded else { return nil } + return TodayFeature.achievement( + incompleteTodos: todos, + completedTodos: completedTodayTodos, + interval: todayInterval + ) + } + + var filteredTodos: [TodayTodoItem] { + TodayFeature.filteredTodos( + todos, + scope: selectedTodoScope, + categoryID: selectedCategoryID + ) + } + + var filteredCompletedTodayTodos: [TodayTodoItem] { + TodayFeature.filteredTodos( + completedTodayTodos, + scope: selectedTodoScope, + categoryID: selectedCategoryID + ) + } + + var sections: [SectionContent] { + var collection = TodayFeature.groupedSectionItems( + from: filteredTodos, + interval: todayInterval + ) + collection.today.append(contentsOf: filteredCompletedTodayTodos) + + return + TodayFeature.makeSection( + category: .overdue, + title: String(localized: "today_section_overdue", bundle: PresentationResources.bundle), + items: collection.overdue + ) + + 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: [TodoScope: Int] { + let todos = TodayFeature.filteredTodos( + todos, + scope: .remaining, + categoryID: selectedCategoryID + ) + return [ + .remaining: todos.count, + .important: todos.filter(\.isPinned).count + ] + } + } + + 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 filteredTodos( + _ todos: [TodayTodoItem], + scope: TodoScope, + categoryID: String? + ) -> [TodayTodoItem] { + 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], + interval: DateInterval, + calendar: Calendar = .autoupdatingCurrent + ) -> SectionCollection { + guard let upcomingEnd = calendar.date( + byAdding: .day, + value: upcomingWindowDays, + to: interval.end + ) else { + return SectionCollection(unscheduled: pinnedFirst(items.filter { $0.dueDate == nil })) + } + + var collection = SectionCollection() + + for item in items { + guard let dueDate = item.dueDate else { + collection.unscheduled.append(item) + continue + } + + if dueDate < interval.start { + collection.overdue.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 makeSection( + category: SectionCategory, + title: String, + items: [TodayTodoItem] + ) -> [SectionContent] { + 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 new file mode 100644 index 00000000..de6c70f9 --- /dev/null +++ b/Application/Presentation/TodayTab/Sources/TodayFeature.swift @@ -0,0 +1,366 @@ +// +// TodayFeature.swift +// TodayTab +// +// Created by opfic on 6/14/26. +// + +import Core +import Domain +import Foundation +import PresentationShared + +@Reducer +struct TodayFeature { + enum TodoScope: Hashable, CaseIterable { + case remaining + case important + } + + enum SectionCategory: Hashable { + case later + case unscheduled + case today + case overdue + case upcoming + } + + struct SectionContent: Identifiable, Equatable { + var id: SectionCategory { category } + let category: SectionCategory + let title: String + let items: [TodayTodoItem] + } + + struct SectionCollection { + var overdue: [TodayTodoItem] = [] + var today: [TodayTodoItem] = [] + var upcoming: [TodayTodoItem] = [] + var later: [TodayTodoItem] = [] + var unscheduled: [TodayTodoItem] = [] + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case binding(BindingAction) + case refresh + case fetchData + case checkCurrentDate(Date) + 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) + + enum StoreAction: Equatable { + case setAlert + case setTodos( + incomplete: [TodayTodoItem], + completedToday: [TodayTodoItem], + 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(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.date.now) var now + + static let pageSize = 20 + static let upcomingWindowDays = 7 + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + BindingReducer() + Reduce { state, action in + switch action { + case .alert: + break + case .binding(\.isTodoInspectorPresented): + if !state.isTodoInspectorPresented { + dismissTodoInspector(state: &state) + } + case .binding: + break + case .refresh: + let interval = prepareTodayInterval(state: &state, now: now) + return .concatenate( + fetchTodosEffect(interval: interval, showsIndicator: false), + fetchCategoriesEffect() + ) + case .fetchData: + let interval = prepareTodayInterval(state: &state, now: now) + return .concatenate( + fetchTodosEffect(interval: interval), + fetchCategoriesEffect() + ) + 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 .setTodoScope(let scope): + state.selectedTodoScope = scope + case .setCategory(let categoryID): + 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)): + 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(.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 } + 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.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 + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.todoEditor, action: \.todoEditor) { + TodoEditorFeature() + } + } +} + +extension DependencyValues { + var todayFetchTodosUseCase: FetchTodosUseCase { + get { self[TodayFetchTodosUseCaseKey.self] } + set { self[TodayFetchTodosUseCaseKey.self] = newValue } + } +} + +private enum TodayFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } + + static var testValue: FetchTodosUseCase { + liveValue + } +} + +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 } + 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))) + } + do { + async let todosWithDueDatePage = fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: Self.pageSize, + fetchAllPages: true + ), + cursor: nil + ) + async let todosWithoutDueDatePage = fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withoutDueDate, + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: Self.pageSize, + fetchAllPages: true + ), + 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:)) + 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))) + } + } catch { + if showsIndicator { + await send(.loading(.end(target: .default, mode: .delayed))) + } + await send(.store(.setAlert)) + } + } + } + + 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)) + } + } + } + + 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, + dueDateFilter: .withDueDate, + sortDateFrom: interval.start, + sortDateTo: interval.end, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: Self.pageSize, + fetchAllPages: true + ) + } + + func completeTodoEffect(_ item: TodayTodoItem) -> Effect { + .run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase, now] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + var todo = try await fetchTodoByIdUseCase.execute(item.id) + todo.isCompleted = true + todo.completedAt = now + todo.updatedAt = now + try await upsertTodoUseCase.execute(todo) + trackAnalyticsEventUseCase.execute(.todoComplete) + 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))) + await send(.store(.setAlert)) + } + } + } + + static func alertState() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String(localized: "common_error_message", bundle: PresentationResources.bundle)) + } + } +} 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 77% rename from Application/Presentation/TodayTab/Sources/Today/TodayTodoItem.swift rename to Application/Presentation/TodayTab/Sources/TodayTodoItem.swift index 908a6969..ca1d1b42 100644 --- a/Application/Presentation/TodayTab/Sources/Today/TodayTodoItem.swift +++ b/Application/Presentation/TodayTab/Sources/TodayTodoItem.swift @@ -9,21 +9,25 @@ import Foundation import Domain public struct TodayTodoItem: Identifiable, Hashable { + let todo: Todo public let id: String public let number: Int public let title: String - public let tags: [String] + public let content: String public let isPinned: Bool + public let isCompleted: Bool public let updatedAt: Date public let dueDate: Date? public let category: TodoCategory init?(from todo: Todo) { + self.todo = todo self.id = todo.id self.number = todo.number self.title = todo.title - self.tags = todo.tags + self.content = todo.content 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 new file mode 100644 index 00000000..2f919b59 --- /dev/null +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -0,0 +1,301 @@ +// +// TodayView.swift +// TodayTab +// +// Created by opfic on 3/6/26. +// + +import SwiftUI +import Core +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 + private let windowEvent: TodoEditorWindowEvent + + public init( + isSelected: Bool, + windowEvent: TodoEditorWindowEvent + ) { + self._store = State(initialValue: Store( + initialState: TodayFeature.State() + ) { + TodayFeature() + }) + self.isSelected = isSelected + self.windowEvent = windowEvent + } + + public var body: some View { + NavigationStack(path: $path) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8, pinnedViews: [.sectionHeaders]) { + Section { + if store.sections.isEmpty, !store.isLoading { + emptyContent + } else { + ForEach(store.sections) { section in + TodoSection( + section: section, + isNavigationEnabled: !store.isTodoInspectorPresented, + onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, + onInspect: { store.send(.showTodoInspector($0)) } + ) + } + .padding(.bottom, 12) + } + } header: { + VStack(alignment: .leading) { + topBar + achievementCard + filterBar + } + .background(Color.appBackground) + } + } + .padding(.horizontal) + } + .background(Color.appBackground) + .refreshable { await store.send(.refresh).finish() } + .toolbarVisibility(.hidden, for: .navigationBar) + .navigationDestination(for: TodayRoute.self, destination: destination) + .sheet(isPresented: $store.isCategoryFilterPresented) { + CategoryFilterSheet(store: store) + } + } + .inspector(isPresented: $store.isTodoInspectorPresented) { + todoInspector + } + .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 { + Text(String(localized: "nav_today", bundle: PresentationResources.bundle)) + .font(.largeTitle.bold()) + .padding(.bottom, 8) + } + + 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.onPrimaryContainer) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.primaryContainer, in: .capsule) + + Spacer(minLength: 8) + + progressCount + } + + ProgressView(value: store.todayAchievement?.progress ?? 0) + .tint(Color.accent) + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + } + } + + @ViewBuilder + private var progressCount: some View { + 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) + } + } + + @ViewBuilder + private var filterBar: some View { + ScrollView(.horizontal) { + LazyHStack(spacing: 8) { + ForEach(TodayFeature.TodoScope.allCases, id: \.self) { scope in + let isSelected = store.selectedTodoScope == scope + Button { + withAnimation(.easeInOut) { + _ = store.send(.setTodoScope(scope)) + } + } label: { + HStack(spacing: 6) { + Text(scope.title) + Text("\(store.summaryCounts[scope, default: 0])") + .fontWeight(.bold) + } + .font(.callout) + .foregroundStyle(isSelected ? Color.onPrimaryContainer : .onControlBackground) + } + .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .controlBackground) + } + + 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.onControlBackground) + Text( + category?.localizedName + ?? String( + localized: "todo_category", + bundle: PresentationResources.bundle + ) + ) + } + .font(.callout) + .foregroundStyle(isSelected ? Color.onPrimaryContainer : .onControlBackground) + } + .adaptiveButtonStyle(color: isSelected ? Color.primaryContainer : .controlBackground) + } + } + .scrollIndicators(.hidden) + .padding(.horizontal, -16) + .contentMargins(.horizontal, 16, for: .scrollContent) + } + + 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) + } + } + + @ViewBuilder + private var todoInspector: some View { + if let editorStore = store.scope(state: \.todoEditor, action: \.todoEditor) { + 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) + } + } + + 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) + } + } + + 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 + } + } + } + + private var achievementStatusTitle: String { + store.todayAchievement?.status.title + ?? String(localized: "today_achievement_status_loading", bundle: PresentationResources.bundle) + } + + private var emptyStateContent: EmptyStateContent { + if store.todos.isEmpty, store.completedTodayTodos.isEmpty { + return EmptyStateContent( + title: String(localized: "today_empty_all_title", bundle: PresentationResources.bundle), + message: String(localized: "today_empty_all_message", bundle: PresentationResources.bundle) + ) + } + if store.hasActiveFilters { + return EmptyStateContent( + 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 { + let title: String + let message: String + } +} + +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" + } + return String(localized: String.LocalizationValue(key), bundle: PresentationResources.bundle) + } +} 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 + } +} diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestAssertions.swift b/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestAssertions.swift deleted file mode 100644 index 059f1950..00000000 --- a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestAssertions.swift +++ /dev/null @@ -1,223 +0,0 @@ -// -// TodayFeatureTestAssertions.swift -// TodayTabTests -// -// Created by opfic on 6/14/26. -// - -import Testing -import Core -import Domain -@testable import TodayTab - -@MainActor -private func waitUntilTodayMainActor( - timeout: Duration = .seconds(1), - pollInterval: Duration = .milliseconds(20), - _ condition: @escaping @MainActor () -> Bool -) async { - let continuousClock = ContinuousClock() - let deadline = continuousClock.now + timeout - - while !condition() && continuousClock.now < deadline { - try? await Task.sleep(for: pollInterval) - } -} - -@MainActor -func verifyTodayFetchData( - adapter: Adapter, - fetchUseCaseSpy: TodayFetchTodosUseCaseSpy -) async throws { - await adapter.fetchData() - - await waitUntilTodayMainActor { - adapter.todos.count == 5 - } - - let queries = await fetchUseCaseSpy.calledQueries() - let queriesByDueDateFilter = Dictionary( - uniqueKeysWithValues: queries.map { ($0.dueDateFilter, $0) } - ) - let cursors = await fetchUseCaseSpy.calledCursors() - - #expect(queries.count == 2) - #expect(Set(queries.map(\.dueDateFilter)) == Set([.withDueDate, .withoutDueDate])) - #expect(queries.allSatisfy { $0.completionFilter == .incomplete }) - #expect(queriesByDueDateFilter[.withDueDate]?.sortTarget == .dueDate) - #expect(queriesByDueDateFilter[.withDueDate]?.sortOrder == .oldest) - #expect(queriesByDueDateFilter[.withoutDueDate]?.sortTarget == .updatedAt) - #expect(queriesByDueDateFilter[.withoutDueDate]?.sortOrder == .latest) - #expect(queries.map(\.pageSize).allSatisfy { $0 == 20 }) - #expect(queries.map(\.fetchAllPages).allSatisfy { $0 }) - #expect(cursors.count == 2) - #expect(cursors.allSatisfy { $0 == nil }) - #expect(adapter.todos.map(\.id) == ["focused", "overdue", "due-soon", "later", "unscheduled"]) - #expect(adapter.summaryCounts == [ - .all: 5, - .focused: 1, - .overdue: 1, - .dueSoon: 2 - ]) - #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused"]), - TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), - TodayDisplayedSection(category: .dueSoon, itemIds: ["due-soon"]), - TodayDisplayedSection(category: .later, itemIds: ["later"]), - TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) - ]) -} - -@MainActor -func verifyTodaySectionScopeToggle( - adapter: Adapter, - fetchUseCaseSpy: TodayFetchTodosUseCaseSpy -) async throws { - try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) - - await adapter.setSectionScope(.focused) - - #expect(adapter.selectedSectionScope == .focused) - #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused"]) - ]) - - await adapter.setSectionScope(.focused) - - #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 - ]) -} - -@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, - fetchUseCaseSpy: TodayFetchTodosUseCaseSpy, - fetchTodoByIdUseCaseSpy: TodayFetchTodoByIdUseCaseSpy, - upsertTodoUseCaseSpy: TodayUpsertTodoUseCaseSpy, - trackAnalyticsEventUseCaseSpy: TodayTrackAnalyticsEventUseCaseSpy -) async throws { - try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) - - let item = try #require(adapter.todos.first { $0.id == "due-soon" }) - await adapter.completeTodo(item) - - await waitUntilTodayMainActor { - !adapter.todos.map(\.id).contains("due-soon") - } - - #expect(fetchTodoByIdUseCaseSpy.todoIds == ["due-soon"]) - #expect(upsertTodoUseCaseSpy.todos.last?.id == "due-soon") - #expect(upsertTodoUseCaseSpy.todos.last?.isCompleted == true) - #expect(trackAnalyticsEventUseCaseSpy.hasTrackedTodoComplete) - #expect(adapter.summaryCounts == [ - .all: 4, - .focused: 1, - .overdue: 1, - .dueSoon: 1 - ]) - #expect(adapter.displayedSections == [ - TodayDisplayedSection(category: .focused, itemIds: ["focused"]), - TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), - TodayDisplayedSection(category: .later, itemIds: ["later"]), - TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) - ]) -} - -@MainActor -func verifyTodayFetchFailureShowsAlert( - adapter: Adapter -) async { - await adapter.fetchData() - - await waitUntilTodayMainActor { - adapter.showAlert - } - - #expect(adapter.showAlert) -} diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestDoubles.swift b/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestDoubles.swift deleted file mode 100644 index 127aabba..00000000 --- a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestDoubles.swift +++ /dev/null @@ -1,197 +0,0 @@ -// -// TodayFeatureTestDoubles.swift -// TodayTabTests -// -// Created by opfic on 6/14/26. -// - -import Foundation -import PresentationShared -import Core -import Domain -@testable import TodayTab - -enum TodayTestError: Error { - case failure -} - -enum TodayTestSectionScope: Hashable, CaseIterable { - case all - case focused - case overdue - case dueSoon -} - -enum TodayTestSectionCategory: Hashable { - case later - case unscheduled - case focused - case overdue - case dueSoon -} - -struct TodayDisplayedSection: Equatable { - let category: TodayTestSectionCategory - let itemIds: [String] -} - -@MainActor -protocol TodayStateDriving { - var todos: [TodayTodoItem] { get } - var selectedSectionScope: TodayTestSectionScope { get } - var displayOptions: TodayDisplayOptions { get } - var showAlert: Bool { get } - var isLoading: Bool { get } - var displayedSections: [TodayDisplayedSection] { get } - var summaryCounts: [TodayTestSectionScope: Int] { get } - - func fetchData() async - func setSectionScope(_ scope: TodayTestSectionScope) async - func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async - func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async - func resetDisplayOptions() async - func completeTodo(_ item: TodayTodoItem) async - func togglePinned(_ item: TodayTodoItem) async -} - -@MainActor -struct TodayStoreTestAdapter: TodayStateDriving { - private let store: TestStoreOf - - var todos: [TodayTodoItem] { store.state.todos } - var selectedSectionScope: TodayTestSectionScope { store.state.selectedSectionScope.testValue } - var displayOptions: TodayDisplayOptions { store.state.displayOptions } - 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] { - Dictionary( - uniqueKeysWithValues: store.state.summaryCounts.map { key, value in - (key.testValue, value) - } - ) - } - - init( - fetchUseCase: FetchTodosUseCase = TodayFetchTodosUseCaseSpy(), - fetchTodoByIdUseCase: FetchTodoByIdUseCase = TodayFetchTodoByIdUseCaseSpy(), - upsertUseCase: UpsertTodoUseCase = TodayUpsertTodoUseCaseSpy(), - fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase = TodayFetchDisplayOptionsUseCaseSpy(), - updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase = TodayUpdateDisplayOptionsUseCaseSpy(), - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodayTrackAnalyticsEventUseCaseSpy(), - configureDependencies: ((inout DependencyValues) -> Void)? = nil - ) { - store = TestStore( - initialState: TodayFeature.State( - displayOptions: fetchDisplayOptionsUseCase.execute() - ) - ) { - TodayFeature() - } withDependencies: { - $0.todayFetchTodosUseCase = fetchUseCase - $0.fetchTodoByIdUseCase = fetchTodoByIdUseCase - $0.upsertTodoUseCase = upsertUseCase - $0.updateTodayDisplayOptionsUseCase = updateDisplayOptionsUseCase - $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - $0.continuousClock = ContinuousClock() - configureDependencies?(&$0) - } - store.exhaustivity = .off(showSkippedAssertions: false) - } - - func fetchData() async { - await store.send(.fetchData) - await drainReceivedActions() - } - - 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 setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async { - await store.send(.binding(.set(\.displayOptions.focusVisibility, visibility))) - await drainReceivedActions() - } - - func resetDisplayOptions() async { - await store.send(.resetDisplayOptions) - await drainReceivedActions() - } - - func completeTodo(_ item: TodayTodoItem) async { - await store.send(.completeTodo(item)) - 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) - } - } -} - -private extension TodayTestSectionScope { - var featureValue: TodayFeature.SectionScope { - switch self { - case .all: - return .all - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon - } - } -} - -private extension TodayFeature.SectionScope { - var testValue: TodayTestSectionScope { - switch self { - case .all: - return .all - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon - } - } -} - -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 - } - } -} - -private extension TodayFeature.SectionContent { - var testValue: TodayDisplayedSection { - TodayDisplayedSection( - category: category.testValue, - itemIds: items.map(\.id) - ) - } -} diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTests.swift b/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTests.swift deleted file mode 100644 index 57b2aae0..00000000 --- a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTests.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// TodayFeatureTests.swift -// TodayTabTests -// -// Created by opfic on 6/14/26. -// - -import Testing -import Foundation -import Core -@testable import TodayTab - -@MainActor -struct TodayFeatureTests { - @Test("TodayFeature groupedSectionItems는 주입된 now 기준으로 섹션을 분류한다") - func TodayFeature_groupedSectionItems는_주입된_now_기준으로_섹션을_분류한다() throws { - let now = try #require(makeFixedTodayNow()) - let items = makeFixedTodayTodoItems(now: now) - - let sections = TodayFeature.groupedSectionItems(from: items, now: now) - - #expect(sections.focused.map(\.id) == ["focused"]) - #expect(sections.overdue.map(\.id) == ["overdue"]) - #expect(sections.dueSoon.map(\.id) == ["due-soon"]) - #expect(sections.later.map(\.id) == ["later"]) - #expect(sections.unscheduled.map(\.id) == ["unscheduled"]) - } - - @Test("TodayFeature summaryValue는 주입된 now 기준으로 요약 값을 계산한다") - func TodayFeature_summaryValue는_주입된_now_기준으로_요약_값을_계산한다() 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 - ) - } - - @Test("TodayFeature fetchData는 요약과 섹션 상태를 갱신한다") - func TodayFeature_fetchData는_요약과_섹션_상태를_갱신한다() 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 verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchSpy) - } - - @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 makeFixedTodayNow() -> Date? { - Calendar.current.date( - from: DateComponents( - year: 2026, - month: 6, - day: 14, - hour: 12 - ) - ) -} - -private func makeFixedTodayTodoItems(now: Date) -> [TodayTodoItem] { - let calendar = Calendar.current - - func dueDate(_ dayOffset: Int) -> Date { - calendar.date(byAdding: .day, value: dayOffset, to: now) ?? now - } - - return [ - TodayTodoItem(from: makeTodayTodo(id: "focused", isPinned: true, dueDate: dueDate(1)))!, - TodayTodoItem(from: makeTodayTodo(id: "overdue", dueDate: dueDate(-1)))!, - TodayTodoItem(from: makeTodayTodo(id: "due-soon", dueDate: dueDate(2)))!, - TodayTodoItem(from: makeTodayTodo(id: "later", dueDate: dueDate(10)))!, - TodayTodoItem(from: makeTodayTodo(id: "unscheduled", dueDate: nil))! - ] -} diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift new file mode 100644 index 00000000..d930d2e4 --- /dev/null +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureSupportTests.swift @@ -0,0 +1,239 @@ +// +// 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 setTodoScope는 남은 일과 중요 보기를 전환한다") + func todayFeature_setTodoScope는_남은_일과_중요_보기를_전환한다() 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 verifyTodayTodoScope(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @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 adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchCategoryPreferencesUseCase: TodayFetchCategoryPreferencesUseCaseSpy( + preferences: categories + ), + now: now + ) + + 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 { + 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) + } + + @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? { + 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 new file mode 100644 index 00000000..f6c03727 --- /dev/null +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestAssertions.swift @@ -0,0 +1,139 @@ +// +// TodayFeatureTestAssertions.swift +// TodayTabTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Core +import Domain +@testable import TodayTab + +@MainActor +private func waitUntilTodayMainActor( + timeout: Duration = .seconds(1), + pollInterval: Duration = .milliseconds(20), + _ condition: @escaping @MainActor () -> Bool +) async { + let continuousClock = ContinuousClock() + let deadline = continuousClock.now + timeout + + while !condition() && continuousClock.now < deadline { + try? await Task.sleep(for: pollInterval) + } +} + +@MainActor +func verifyTodayFetchData( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy +) async throws { + await adapter.fetchData() + + await waitUntilTodayMainActor { + adapter.todos.count == 5 && adapter.isTodayDataLoaded + } + + let queries = await fetchUseCaseSpy.calledQueries() + 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 == 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 == 3) + #expect(cursors.allSatisfy { $0 == nil }) + #expect(adapter.todos.map(\.id) == ["focused", "overdue", "due-soon", "later", "unscheduled"]) + #expect(adapter.summaryCounts == [ + .remaining: 5, + .important: 1 + ]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), + TodayDisplayedSection(category: .upcoming, itemIds: ["focused", "due-soon"]), + TodayDisplayedSection(category: .later, itemIds: ["later"]), + TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) + ]) +} + +@MainActor +func verifyTodayTodoScope( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy +) async throws { + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) + + await adapter.setTodoScope(.important) + + #expect(adapter.selectedTodoScope == .important) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .upcoming, itemIds: ["focused"]) + ]) + + await adapter.setTodoScope(.remaining) + + #expect(adapter.selectedTodoScope == .remaining) + #expect(adapter.displayedSections.count == 4) +} + +@MainActor +func verifyTodayCompleteTodo( + adapter: Adapter, + fetchUseCaseSpy: TodayFetchTodosUseCaseSpy, + fetchTodoByIdUseCaseSpy: TodayFetchTodoByIdUseCaseSpy, + upsertTodoUseCaseSpy: TodayUpsertTodoUseCaseSpy, + trackAnalyticsEventUseCaseSpy: TodayTrackAnalyticsEventUseCaseSpy +) async throws { + try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchUseCaseSpy) + + let item = try #require(adapter.todos.first { $0.id == "due-soon" }) + await adapter.completeTodo(item) + + await waitUntilTodayMainActor { + !adapter.todos.map(\.id).contains("due-soon") + } + + #expect(fetchTodoByIdUseCaseSpy.todoIds == ["due-soon"]) + #expect(upsertTodoUseCaseSpy.todos.last?.id == "due-soon") + #expect(upsertTodoUseCaseSpy.todos.last?.isCompleted == true) + #expect(trackAnalyticsEventUseCaseSpy.hasTrackedTodoComplete) + #expect(adapter.summaryCounts == [ + .remaining: 4, + .important: 1 + ]) + #expect(adapter.displayedSections == [ + TodayDisplayedSection(category: .overdue, itemIds: ["overdue"]), + TodayDisplayedSection(category: .upcoming, itemIds: ["focused"]), + TodayDisplayedSection(category: .later, itemIds: ["later"]), + TodayDisplayedSection(category: .unscheduled, itemIds: ["unscheduled"]) + ]) +} + +@MainActor +func verifyTodayFetchFailureShowsAlert( + adapter: Adapter +) async { + await adapter.fetchData() + + await waitUntilTodayMainActor { + adapter.showAlert + } + + #expect(adapter.showAlert) +} diff --git a/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift new file mode 100644 index 00000000..e535bc85 --- /dev/null +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTestDoubles.swift @@ -0,0 +1,226 @@ +// +// TodayFeatureTestDoubles.swift +// TodayTabTests +// +// Created by opfic on 6/14/26. +// + +import Foundation +import PresentationShared +import Domain +@testable import TodayTab + +enum TodayTestError: Error { + case failure +} + +enum TodayTestTodoScope: Hashable, CaseIterable { + case remaining + case important +} + +enum TodayTestSectionCategory: Hashable { + case overdue + case today + case upcoming + case later + case unscheduled +} + +struct TodayDisplayedSection: Equatable { + let category: TodayTestSectionCategory + let itemIds: [String] +} + +@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 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 } + var displayedSections: [TodayDisplayedSection] { get } + var summaryCounts: [TodayTestTodoScope: Int] { get } + + func fetchData() async + func checkCurrentDate(_ date: Date) async + func setTodoScope(_ scope: TodayTestTodoScope) async + 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 +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 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 } + var displayedSections: [TodayDisplayedSection] { store.state.sections.map(\.testValue) } + var summaryCounts: [TodayTestTodoScope: Int] { + Dictionary( + uniqueKeysWithValues: store.state.summaryCounts.map { key, value in + (key.testValue, value) + } + ) + } + + init( + fetchUseCase: FetchTodosUseCase = TodayFetchTodosUseCaseSpy(), + fetchCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase + = TodayFetchCategoryPreferencesUseCaseSpy(), + fetchTodoByIdUseCase: FetchTodoByIdUseCase = TodayFetchTodoByIdUseCaseSpy(), + upsertUseCase: UpsertTodoUseCase = TodayUpsertTodoUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodayTrackAnalyticsEventUseCaseSpy(), + now: Date = Date(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + store = TestStore(initialState: TodayFeature.State(now: now)) { + TodayFeature() + } withDependencies: { + $0.todayFetchTodosUseCase = fetchUseCase + $0.fetchTodoCategoryPreferencesUseCase = fetchCategoryPreferencesUseCase + $0.fetchTodoByIdUseCase = fetchTodoByIdUseCase + $0.upsertTodoUseCase = upsertUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.date.now = now + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func fetchData() async { + await store.send(.fetchData) + 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 setTodoScope(_ scope: TodayTestTodoScope) async { + await store.send(.setTodoScope(scope.featureValue)) + } + + func setCategory(_ categoryID: String?) async { + await store.send(.setCategory(categoryID)) + } + + func setCategoryFilterPresented(_ isPresented: Bool) async { + await store.send(.binding(.set(\.isCategoryFilterPresented, isPresented))) + } + + func completeTodo(_ item: TodayTodoItem) async { + await store.send(.completeTodo(item)) + 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) + } + } +} + +private extension TodayTestTodoScope { + var featureValue: TodayFeature.TodoScope { + switch self { + case .remaining: .remaining + case .important: .important + } + } +} + +private extension TodayFeature.TodoScope { + var testValue: TodayTestTodoScope { + switch self { + case .remaining: .remaining + case .important: .important + } + } +} + +private extension TodayFeature.SectionCategory { + var testValue: TodayTestSectionCategory { + switch self { + case .overdue: .overdue + case .today: .today + case .upcoming: .upcoming + case .later: .later + case .unscheduled: .unscheduled + } + } +} + +private extension TodayFeature.SectionContent { + var testValue: TodayDisplayedSection { + TodayDisplayedSection( + category: category.testValue, + itemIds: items.map(\.id) + ) + } +} diff --git a/Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestSpies.swift b/Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift similarity index 81% rename from Application/Presentation/TodayTab/Tests/Today/TodayFeatureTestSpies.swift rename to Application/Presentation/TodayTab/Tests/TodayFeatureTestSpies.swift index 398ce3c0..d5099676 100644 --- a/Application/Presentation/TodayTab/Tests/Today/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) } @@ -103,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 } } @@ -138,26 +145,29 @@ func makeTodayTodo( id: String = "todo-1", isPinned: Bool = false, isCompleted: Bool = false, + isChecked: Bool = false, number: Int = 1, title: String = "Todo", - dueDate: Date? = nil + content: String = "content", + dueDate: Date? = nil, + category: TodoCategory = .system(.feature) ) -> Todo { let now = Date(timeIntervalSince1970: 0) return Todo( 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, 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 new file mode 100644 index 00000000..09160b12 --- /dev/null +++ b/Application/Presentation/TodayTab/Tests/TodayFeatureTests.swift @@ -0,0 +1,372 @@ +// +// TodayFeatureTests.swift +// TodayTabTests +// +// Created by opfic on 6/14/26. +// + +import Testing +import Foundation +import Core +import Domain +@testable import TodayTab + +@MainActor +struct TodayFeatureTests { + @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, interval: interval) + + #expect(sections.overdue.map(\.id) == ["overdue"]) + #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 filteredTodos는 보기와 카테고리 조건을 함께 적용한다") + func todayFeature_filteredTodos는_보기와_카테고리_조건을_함께_적용한다() throws { + let now = try #require(makeFixedTodayNow()) + 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는 선택한 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() + 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 verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @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: TodoPage(items: [incompleteToday], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) + ], + completedTodayPage: TodoPage(items: [completedToday], nextCursor: nil) + ) + let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy, now: now) + + 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[.remaining] == 1) + } + + @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: 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 fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: [todo]) + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + now: now + ) + await adapter.fetchData() + let item = try #require(adapter.todos.first) + + 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 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: TodoPage(items: [original], nextCursor: nil), + .withoutDueDate: TodoPage(items: [], nextCursor: nil) + ] + ) + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: TodayFetchTodoByIdUseCaseSpy(todos: [latest]), + now: now + ) + await adapter.fetchData() + let item = try #require(adapter.todos.first) + + 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 실패는 오늘 달성 상태를 유지한다") + func todayFeature_completeTodo_실패는_오늘_달성_상태를_유지한다() 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 upsertSpy = TodayUpsertTodoUseCaseSpy() + upsertSpy.error = TodayTestError.failure + let adapter = TodayStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: TodayFetchTodoByIdUseCaseSpy(todos: [todo]), + upsertUseCase: upsertSpy, + now: now + ) + await adapter.fetchData() + let item = try #require(adapter.todos.first) + + 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 달성 상태는 목록 필터의 영향을 받지 않는다") + 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) + 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 adapter.setTodoScope(.important) + await adapter.setCategory(SystemTodoCategory.doc.rawValue) + + #expect(adapter.todayAchievement?.completedCount == 1) + #expect(adapter.todayAchievement?.totalCount == 2) + #expect(adapter.todayAchievement?.status == .inProgress) + } + +} + +private func makeFixedTodayNow() -> Date? { + Calendar.current.date( + from: DateComponents( + year: 2026, + month: 6, + day: 14, + hour: 12 + ) + ) +} + +private func makeFixedTodayTodoItems(now: Date) -> [TodayTodoItem] { + let calendar = Calendar.current + + func dueDate(_ dayOffset: Int) -> Date { + calendar.date(byAdding: .day, value: dayOffset, to: now) ?? now + } + + return [ + TodayTodoItem(from: makeTodayTodo(id: "focused", isPinned: true, dueDate: dueDate(1)))!, + TodayTodoItem(from: makeTodayTodo(id: "overdue", dueDate: dueDate(-1)))!, + TodayTodoItem(from: makeTodayTodo(id: "due-soon", dueDate: dueDate(2)))!, + TodayTodoItem(from: makeTodayTodo(id: "later", dueDate: dueDate(10)))!, + TodayTodoItem(from: makeTodayTodo(id: "unscheduled", dueDate: nil))! + ] +} 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/WarningContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/ControlBackground.colorset/Contents.json similarity index 76% rename from Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/ControlBackground.colorset/Contents.json index 00ae8e5c..ddba8151 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/WarningContainer.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": "0xE5", - "green": "0xF4", - "red": "0xFF" + "blue": "0xF0", + "green": "0xED", + "red": "0xE9" } }, "idiom": "universal" @@ -23,9 +23,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0x16", - "green": "0x2F", - "red": "0x3D" + "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/InfoContainer.colorset/Contents.json deleted file mode 100644 index df9c5263..00000000 --- a/Application/Shared/Resources/ColorPalette.xcassets/InfoContainer.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors": [ - { - "color": { - "color-space": "srgb", - "components": { - "alpha": "1.000", - "blue": "0xFF", - "green": "0xF3", - "red": "0xE8" - } - }, - "idiom": "universal" - }, - { - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ], - "color": { - "color-space": "srgb", - "components": { - "alpha": "1.000", - "blue": "0x4A", - "green": "0x38", - "red": "0x16" - } - }, - "idiom": "universal" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.colorset/Contents.json b/Application/Shared/Resources/ColorPalette.xcassets/OnControlBackground.colorset/Contents.json similarity index 76% rename from Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.colorset/Contents.json rename to Application/Shared/Resources/ColorPalette.xcassets/OnControlBackground.colorset/Contents.json index 912bd8a0..62533914 100644 --- a/Application/Shared/Resources/ColorPalette.xcassets/DangerContainer.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": "0xEE", - "green": "0xEC", - "red": "0xFE" + "blue": "0x6E", + "green": "0x63", + "red": "0x59" } }, "idiom": "universal" @@ -23,9 +23,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0x22", - "green": "0x1E", - "red": "0x45" + "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"