diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index ab6d86bd..734ff1b7 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -154,5 +154,13 @@ private extension DomainAssembler { container.register(UpdateProfileHeatmapActivityTypesUseCase.self) { UpdateProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } + + container.register(FetchTodayDisplayOptionsUseCase.self) { + FetchTodayDisplayOptionsUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } + + container.register(UpdateTodayDisplayOptionsUseCase.self) { + UpdateTodayDisplayOptionsUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } } } diff --git a/DevLog/Data/DTO/TodoCursorDTO.swift b/DevLog/Data/DTO/TodoCursorDTO.swift index 09abf1fd..d7fb22d5 100644 --- a/DevLog/Data/DTO/TodoCursorDTO.swift +++ b/DevLog/Data/DTO/TodoCursorDTO.swift @@ -8,6 +8,7 @@ import Foundation struct TodoCursorDTO { - let orderedAt: Date + let primarySortDate: Date? + let secondarySortDate: Date? let documentID: String } diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index e7244626..26a9a383 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -50,14 +50,16 @@ extension TodoResponse { extension TodoCursorDTO { func toDomain() -> TodoCursor { TodoCursor( - orderedAt: orderedAt, + primarySortDate: primarySortDate, + secondarySortDate: secondarySortDate, documentID: documentID ) } static func fromDomain(_ cursor: TodoCursor) -> Self { TodoCursorDTO( - orderedAt: cursor.orderedAt, + primarySortDate: cursor.primarySortDate, + secondarySortDate: cursor.secondarySortDate, documentID: cursor.documentID ) } diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift index 95bde867..7c6669ec 100644 --- a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -17,6 +17,8 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { static let pushTimeFilter = "PushNotification.timeFilter" static let pushUnreadOnly = "PushNotification.showUnreadOnly" static let profileHeatmapActivityTypes = "Profile.heatmap.activityTypes" + static let todayDueDateVisibility = "Today.dueDateVisibility" + static let todayFocusVisibility = "Today.focusVisibility" } private let store: UserDefaultsStore @@ -101,4 +103,23 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setProfileHeatmapActivityTypes(_ activityTypes: [String]) { store.setStringArray(activityTypes, forKey: Key.profileHeatmapActivityTypes) } + + func todayDisplayOptions() -> TodayDisplayOptions { + let dueDateVisibilityRawValue = store.string(forKey: Key.todayDueDateVisibility) + let focusVisibilityRawValue = store.string(forKey: Key.todayFocusVisibility) + + return TodayDisplayOptions( + dueDateVisibility: TodayDisplayOptions.DueDateVisibility( + rawValue: dueDateVisibilityRawValue ?? "" + ) ?? .all, + focusVisibility: TodayDisplayOptions.FocusVisibility( + rawValue: focusVisibilityRawValue ?? "" + ) ?? .all + ) + } + + func setTodayDisplayOptions(_ options: TodayDisplayOptions) { + store.setString(options.dueDateVisibility.rawValue, forKey: Key.todayDueDateVisibility) + store.setString(options.focusVisibility.rawValue, forKey: Key.todayFocusVisibility) + } } diff --git a/DevLog/Domain/Entity/TodayDisplayOptions.swift b/DevLog/Domain/Entity/TodayDisplayOptions.swift new file mode 100644 index 00000000..af6d9318 --- /dev/null +++ b/DevLog/Domain/Entity/TodayDisplayOptions.swift @@ -0,0 +1,29 @@ +// +// TodayDisplayOptions.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Foundation + +struct TodayDisplayOptions: Equatable { + enum DueDateVisibility: String, CaseIterable, Equatable { + case all + case withDueDateOnly + case withoutDueDateOnly + } + + enum FocusVisibility: String, CaseIterable, Equatable { + case all + case focusedOnly + } + + var dueDateVisibility: DueDateVisibility + var focusVisibility: FocusVisibility + + static let `default` = TodayDisplayOptions( + dueDateVisibility: .all, + focusVisibility: .all + ) +} diff --git a/DevLog/Domain/Entity/TodoCursor.swift b/DevLog/Domain/Entity/TodoCursor.swift index 552cd98f..3dd172ca 100644 --- a/DevLog/Domain/Entity/TodoCursor.swift +++ b/DevLog/Domain/Entity/TodoCursor.swift @@ -8,6 +8,7 @@ import Foundation struct TodoCursor { - let orderedAt: Date + let primarySortDate: Date? + let secondarySortDate: Date? let documentID: String } diff --git a/DevLog/Domain/Entity/TodoQuery.swift b/DevLog/Domain/Entity/TodoQuery.swift index 10689a86..57e4d830 100644 --- a/DevLog/Domain/Entity/TodoQuery.swift +++ b/DevLog/Domain/Entity/TodoQuery.swift @@ -11,6 +11,7 @@ struct TodoQuery: Equatable { enum SortTarget: Equatable, Hashable { case createdAt case updatedAt + case dueDate var fieldName: String { switch self { @@ -18,6 +19,8 @@ struct TodoQuery: Equatable { return "createdAt" case .updatedAt: return "updatedAt" + case .dueDate: + return "dueDate" } } } @@ -48,10 +51,17 @@ struct TodoQuery: Equatable { } } + enum DueDateFilter: Equatable, Hashable { + case all + case withDueDate + case withoutDueDate + } + var kind: TodoKind? var keyword: String? var isPinned: Bool? var completionFilter: CompletionFilter + var dueDateFilter: DueDateFilter var createdAtFrom: Date? var createdAtTo: Date? var sortTarget: SortTarget @@ -64,6 +74,7 @@ struct TodoQuery: Equatable { keyword: String? = nil, isPinned: Bool? = nil, completionFilter: CompletionFilter = .all, + dueDateFilter: DueDateFilter = .all, createdAtFrom: Date? = nil, createdAtTo: Date? = nil, sortTarget: SortTarget = .createdAt, @@ -75,6 +86,7 @@ struct TodoQuery: Equatable { self.keyword = keyword self.isPinned = isPinned self.completionFilter = completionFilter + self.dueDateFilter = dueDateFilter self.createdAtFrom = createdAtFrom self.createdAtTo = createdAtTo self.sortTarget = sortTarget diff --git a/DevLog/Domain/Protocol/UserPreferencesRepository.swift b/DevLog/Domain/Protocol/UserPreferencesRepository.swift index ba27fb45..2f0a700e 100644 --- a/DevLog/Domain/Protocol/UserPreferencesRepository.swift +++ b/DevLog/Domain/Protocol/UserPreferencesRepository.swift @@ -30,4 +30,7 @@ protocol UserPreferencesRepository { func profileHeatmapActivityTypes() -> [String] func setProfileHeatmapActivityTypes(_ activityTypes: [String]) + + func todayDisplayOptions() -> TodayDisplayOptions + func setTodayDisplayOptions(_ options: TodayDisplayOptions) } diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCase.swift new file mode 100644 index 00000000..2bad555c --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchTodayDisplayOptionsUseCase.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +protocol FetchTodayDisplayOptionsUseCase { + func execute() -> TodayDisplayOptions +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCaseImpl.swift new file mode 100644 index 00000000..610c796f --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchTodayDisplayOptionsUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +final class FetchTodayDisplayOptionsUseCaseImpl: FetchTodayDisplayOptionsUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute() -> TodayDisplayOptions { + repository.todayDisplayOptions() + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCase.swift new file mode 100644 index 00000000..64f91b08 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateTodayDisplayOptionsUseCase.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +protocol UpdateTodayDisplayOptionsUseCase { + func execute(_ options: TodayDisplayOptions) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCaseImpl.swift new file mode 100644 index 00000000..787ce9f7 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateTodayDisplayOptionsUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +final class UpdateTodayDisplayOptionsUseCaseImpl: UpdateTodayDisplayOptionsUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ options: TodayDisplayOptions) { + repository.setTodayDisplayOptions(options) + } +} diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index f6df99b8..dd6f8c35 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -13,6 +13,7 @@ final class TodoService { private let encoder = Firestore.Encoder() private let logger = Logger(category: "TodoService") + // swiftlint:disable function_body_length func fetchTodos( _ query: TodoQuery, cursor: TodoCursorDTO? @@ -27,6 +28,7 @@ final class TodoService { query.kind != nil ? "kind=\(query.kind!.rawValue)" : nil, query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil, query.completionFilter.isCompletedValue != nil ? "completed=\(query.completionFilter.isCompletedValue!)" : nil, + query.dueDateFilter != .all ? "dueDateFilter=\(query.dueDateFilter)" : nil, query.createdAtFrom != nil ? "createdAtFrom=\(query.createdAtFrom!)" : nil, query.createdAtTo != nil ? "createdAtTo=\(query.createdAtTo!)" : nil, "pageSize=\(query.pageSize)", @@ -35,10 +37,7 @@ final class TodoService { ] logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))") - var firestoreQuery: Query = store - .collection("users/\(uid)/todoLists/") - .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) - .order(by: FieldPath.documentID()) + var firestoreQuery: Query = makeOrderedQuery(uid: uid, query: query) if let kind = query.kind { firestoreQuery = firestoreQuery.whereField("kind", isEqualTo: kind.rawValue) @@ -52,6 +51,18 @@ final class TodoService { firestoreQuery = firestoreQuery.whereField("isCompleted", isEqualTo: isCompleted) } + switch query.dueDateFilter { + case .all: + break + case .withDueDate: + firestoreQuery = firestoreQuery.whereField( + "dueDate", + isGreaterThan: Timestamp(date: Date(timeIntervalSince1970: 0)) + ) + case .withoutDueDate: + firestoreQuery = firestoreQuery.whereField("dueDate", isEqualTo: NSNull()) + } + if let createdAtFrom = query.createdAtFrom { firestoreQuery = firestoreQuery.whereField( "createdAt", @@ -74,10 +85,11 @@ final class TodoService { while true { var pageQuery = firestoreQuery if let pageCursor { - pageQuery = pageQuery.start(after: [ - Timestamp(date: pageCursor.orderedAt), - pageCursor.documentID - ]) + guard let cursorValues = cursorValues(for: query, cursor: pageCursor) else { + logger.error("Failed to build cursor values for paginated todo fetch.") + break + } + pageQuery = pageQuery.start(after: cursorValues) } pageQuery = pageQuery.limit(to: query.pageSize) @@ -91,7 +103,7 @@ final class TodoService { guard let lastDocument = snapshot.documents.last, let nextCursor = makeCursor( from: lastDocument, - orderField: query.sortTarget.fieldName + query: query ) else { break } @@ -103,17 +115,18 @@ final class TodoService { } if let cursor { - firestoreQuery = firestoreQuery.start(after: [ - Timestamp(date: cursor.orderedAt), - cursor.documentID - ]) + guard let cursorValues = cursorValues(for: query, cursor: cursor) else { + logger.error("Failed to build cursor values for todo fetch.") + return TodoPageResponse(items: [], nextCursor: nil) + } + firestoreQuery = firestoreQuery.start(after: cursorValues) } firestoreQuery = firestoreQuery.limit(to: query.pageSize) let snapshot = try await firestoreQuery.getDocuments() let items = snapshot.documents.compactMap { makeResponse(from: $0) } let nextCursor = snapshot.documents.last.flatMap { - makeCursor(from: $0, orderField: query.sortTarget.fieldName) + makeCursor(from: $0, query: query) } return TodoPageResponse(items: items, nextCursor: nextCursor) @@ -130,6 +143,7 @@ final class TodoService { return TodoPageResponse(items: filtered, nextCursor: nil) } + // swiftlint:enable function_body_length func upsertTodo(request: TodoRequest) async throws { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } @@ -195,16 +209,74 @@ final class TodoService { } private extension TodoService { + func makeOrderedQuery(uid: String, query: TodoQuery) -> Query { + let collection = store.collection("users/\(uid)/todoLists/") + + switch query.sortTarget { + case .dueDate: + return collection + .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) + .order(by: "updatedAt", descending: true) + .order(by: FieldPath.documentID()) + case .createdAt, .updatedAt: + return collection + .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) + .order(by: FieldPath.documentID()) + } + } + + func cursorValues( + for query: TodoQuery, + cursor: TodoCursorDTO + ) -> [Any]? { + let primaryValue: Any = cursor.primarySortDate.map { Timestamp(date: $0) } ?? NSNull() + + switch query.sortTarget { + case .dueDate: + guard let sortDate = cursor.secondarySortDate else { return nil } + return [ + primaryValue, + Timestamp(date: sortDate), + cursor.documentID + ] + case .createdAt, .updatedAt: + return [ + primaryValue, + cursor.documentID + ] + } + } + func makeCursor( from document: QueryDocumentSnapshot, - orderField: String + query: TodoQuery ) -> TodoCursorDTO? { - guard let orderedAt = document.data()[orderField] as? Timestamp else { + let data = document.data() + let orderField = query.sortTarget.fieldName + let primarySortDate: Date? + let secondarySortDate: Date? + + if let timestamp = data[orderField] as? Timestamp { + primarySortDate = timestamp.dateValue() + } else if data[orderField] is NSNull { + primarySortDate = nil + } else { return nil } + switch query.sortTarget { + case .dueDate: + guard let updatedAt = data["updatedAt"] as? Timestamp else { + return nil + } + secondarySortDate = updatedAt.dateValue() + case .createdAt, .updatedAt: + secondarySortDate = nil + } + return TodoCursorDTO( - orderedAt: orderedAt.dateValue(), + primarySortDate: primarySortDate, + secondarySortDate: secondarySortDate, documentID: document.documentID ) } diff --git a/DevLog/Presentation/Structure/TodayTodoItem.swift b/DevLog/Presentation/Structure/TodayTodoItem.swift new file mode 100644 index 00000000..525e4f54 --- /dev/null +++ b/DevLog/Presentation/Structure/TodayTodoItem.swift @@ -0,0 +1,28 @@ +// +// TodayTodoItem.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Foundation + +struct TodayTodoItem: Identifiable, Hashable { + let id: String + let title: String + let tags: [String] + let isPinned: Bool + let updatedAt: Date + let dueDate: Date? + let kind: TodoKind + + init(from todo: Todo) { + self.id = todo.id + self.title = todo.title + self.tags = todo.tags + self.isPinned = todo.isPinned + self.updatedAt = todo.updatedAt + self.dueDate = todo.dueDate + self.kind = todo.kind + } +} diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift new file mode 100644 index 00000000..94d1f23e --- /dev/null +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -0,0 +1,352 @@ +// +// TodayViewModel.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Foundation + +@Observable +final class TodayViewModel: Store { + enum SummaryScope: Hashable, CaseIterable { + case all + case focused + case overdue + case dueSoon + } + + struct SectionContent: Identifiable, Equatable { + var id: String { title } + let title: String + let items: [TodayTodoItem] + } + + struct SectionBuckets { + var focused: [TodayTodoItem] = [] + var overdue: [TodayTodoItem] = [] + var dueSoon: [TodayTodoItem] = [] + var later: [TodayTodoItem] = [] + var unscheduled: [TodayTodoItem] = [] + } + + struct State: Equatable { + var todos: [TodayTodoItem] = [] + var isLoading: Bool = false + var showAlert: Bool = false + var alertTitle: String = "" + var alertMessage: String = "" + var selectedSummaryScope: SummaryScope = .all + var displayOptions: TodayDisplayOptions = .default + } + + enum Action { + case refresh + case setAlert(Bool) + case setSummaryScope(SummaryScope) + case setDueDateVisibility(TodayDisplayOptions.DueDateVisibility) + case setFocusVisibility(TodayDisplayOptions.FocusVisibility) + case resetDisplayOptions + case completeTodo(TodayTodoItem) + case togglePinned(TodayTodoItem) + case onAppear + case fetchTodos([TodayTodoItem]) + case setLoading(Bool) + case updateTodo(TodayTodoItem) + case removeTodo(String) + } + + enum SideEffect { + case fetchTodos + case completeTodo(TodayTodoItem) + case togglePinned(TodayTodoItem) + } + + private(set) var state = State() + private let calendar = Calendar.current + private let pageSize = 20 + private let upcomingWindowDays = 7 + private let fetchTodosUseCase: FetchTodosUseCase + private let fetchTodoByIDUseCase: FetchTodoByIDUseCase + private let upsertTodoUseCase: UpsertTodoUseCase + private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase + + init( + fetchTodosUseCase: FetchTodosUseCase, + fetchTodoByIDUseCase: FetchTodoByIDUseCase, + upsertTodoUseCase: UpsertTodoUseCase, + fetchTodayDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase, + updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase + ) { + self.fetchTodosUseCase = fetchTodosUseCase + self.fetchTodoByIDUseCase = fetchTodoByIDUseCase + self.upsertTodoUseCase = upsertTodoUseCase + self.updateTodayDisplayOptionsUseCase = updateTodayDisplayOptionsUseCase + self.state.displayOptions = fetchTodayDisplayOptionsUseCase.execute() + } + + var sections: [SectionContent] { + let groupedItems = groupedSectionItems(from: displayedTodos) + let allSections: [SectionContent] = [ + SectionContent(title: "집중할 일", items: groupedItems.focused), + SectionContent(title: "지난 마감", items: groupedItems.overdue), + SectionContent(title: "\(upcomingWindowDays)일 내 일정", items: groupedItems.dueSoon), + SectionContent(title: "나중 일정", items: groupedItems.later), + SectionContent(title: "일정 미정", items: groupedItems.unscheduled) + ] + + switch state.selectedSummaryScope { + case .all: + return allSections.filter { !$0.items.isEmpty } + case .focused: + return allSections.filter { $0.title == "집중할 일" && !$0.items.isEmpty } + case .overdue: + return allSections.filter { $0.title == "지난 마감" && !$0.items.isEmpty } + case .dueSoon: + return allSections.filter { $0.title == "\(upcomingWindowDays)일 내 일정" && !$0.items.isEmpty } + } + } + + func summaryValue(for scope: SummaryScope) -> Int { + switch scope { + case .all: + return displayedTodos.count + case .focused: + return displayedTodos.filter(\.isPinned).count + case .overdue: + return displayedTodos.filter(isOverdue).count + case .dueSoon: + return displayedTodos.filter(isDueSoon).count + } + } + + func reduce(with action: Action) -> [SideEffect] { + var state = self.state + var effects: [SideEffect] = [] + + switch action { + case .refresh, .setAlert, .setSummaryScope, .setDueDateVisibility, .setFocusVisibility, + .resetDisplayOptions, .completeTodo, .togglePinned: + effects = reduceByUser(action, state: &state) + case .onAppear: + effects = reduceByView(action, state: &state) + case .fetchTodos, .setLoading, .updateTodo, .removeTodo: + effects = reduceByRun(action, state: &state) + } + + if self.state != state { self.state = state } + return effects + } + + func run(_ effect: SideEffect) { + switch effect { + case .fetchTodos: + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + async let todosWithDueDatePage = fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + async let todosWithoutDueDatePage = fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withoutDueDate, + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + let todosWithDueDate = try await todosWithDueDatePage.items.map { TodayTodoItem(from: $0) } + let todosWithoutDueDate = try await todosWithoutDueDatePage.items.map { TodayTodoItem(from: $0) } + send(.fetchTodos(todosWithDueDate + todosWithoutDueDate)) + } catch { + send(.setAlert(true)) + } + } + case .completeTodo(let item): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + 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) + send(.removeTodo(todo.id)) + } catch { + send(.setAlert(true)) + } + } + case .togglePinned(let item): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + var todo = try await fetchTodoByIDUseCase.execute(item.id) + todo.isPinned.toggle() + todo.updatedAt = Date() + try await upsertTodoUseCase.execute(todo) + send(.updateTodo(TodayTodoItem(from: todo))) + } catch { + send(.setAlert(true)) + } + } + } + } +} + +private extension TodayViewModel { + func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .refresh: + return [.fetchTodos] + case .setAlert(let isPresented): + setAlert(&state, isPresented: isPresented) + case .setSummaryScope(let scope): + if state.selectedSummaryScope == scope, scope != .all { + state.selectedSummaryScope = .all + } else { + state.selectedSummaryScope = scope + } + case .setDueDateVisibility(let visibility): + state.displayOptions.dueDateVisibility = visibility + updateTodayDisplayOptionsUseCase.execute(state.displayOptions) + case .setFocusVisibility(let visibility): + state.displayOptions.focusVisibility = visibility + updateTodayDisplayOptionsUseCase.execute(state.displayOptions) + case .resetDisplayOptions: + state.displayOptions = .default + updateTodayDisplayOptionsUseCase.execute(state.displayOptions) + case .completeTodo(let item): + return [.completeTodo(item)] + case .togglePinned(let item): + return [.togglePinned(item)] + default: + break + } + return [] + } + + func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .onAppear: + return [.fetchTodos] + default: + break + } + return [] + } + + func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .fetchTodos(let items): + state.todos = items + case .setLoading(let isLoading): + state.isLoading = isLoading + case .updateTodo(let item): + if let index = state.todos.firstIndex(where: { $0.id == item.id }) { + state.todos[index] = item + } else { + state.todos.append(item) + } + case .removeTodo(let todoID): + state.todos.removeAll { $0.id == todoID } + default: + break + } + return [] + } + + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + state.showAlert = isPresented + } + + var displayedTodos: [TodayTodoItem] { + let dueDateFilteredTodos: [TodayTodoItem] + switch state.displayOptions.dueDateVisibility { + case .all: + dueDateFilteredTodos = state.todos + case .withDueDateOnly: + dueDateFilteredTodos = state.todos.filter { $0.dueDate != nil } + case .withoutDueDateOnly: + dueDateFilteredTodos = state.todos.filter { $0.dueDate == nil } + } + + switch state.displayOptions.focusVisibility { + case .all: + return dueDateFilteredTodos + case .focusedOnly: + return dueDateFilteredTodos.filter(\.isPinned) + } + } + + func groupedSectionItems( + from items: [TodayTodoItem] + ) -> SectionBuckets { + let startOfToday = calendar.startOfDay(for: Date()) + guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { + return SectionBuckets( + focused: items.filter(\.isPinned), + unscheduled: items.filter { !$0.isPinned && $0.dueDate == nil } + ) + } + + var buckets = SectionBuckets() + + for item in items { + if item.isPinned { + buckets.focused.append(item) + continue + } + + guard let dueDate = item.dueDate else { + buckets.unscheduled.append(item) + continue + } + + let dueDay = calendar.startOfDay(for: dueDate) + if dueDay < startOfToday { + buckets.overdue.append(item) + } else if dueDay <= windowEnd { + buckets.dueSoon.append(item) + } else { + buckets.later.append(item) + } + } + + return buckets + } + + func isOverdue(_ item: TodayTodoItem) -> Bool { + guard let dueDate = item.dueDate else { return false } + return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) + } + + func isDueSoon(_ item: TodayTodoItem) -> Bool { + guard let dueDate = item.dueDate else { return false } + let startOfToday = calendar.startOfDay(for: Date()) + guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { + return false + } + let dueDay = calendar.startOfDay(for: dueDate) + return startOfToday <= dueDay && dueDay <= windowEnd + } +} diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 24134f88..29d06d50 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -419,6 +419,8 @@ extension TodoQuery.SortTarget { return "생성" case .updatedAt: return "수정" + case .dueDate: + return "마감" } } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 89a502fe..33582fa8 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -293,6 +293,9 @@ }, "베타 테스트 참여" : { + }, + "보기 범위" : { + }, "분기별 활동 히트맵" : { @@ -335,6 +338,9 @@ }, "연동된 계정" : { + }, + "오늘" : { + }, "완료" : { @@ -384,6 +390,12 @@ }, "중요 표시" : { + }, + "중요 표시만" : { + + }, + "중요 표시한 Todo만 표시됩니다." : { + }, "최근 검색" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 69e4cef6..0d24daf4 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -23,6 +23,17 @@ struct MainView: View { Image(systemName: "house.fill") Text("홈") } + TodayView(viewModel: TodayViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIDUseCase: container.resolve(FetchTodoByIDUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), + updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) + )) + .tabItem { + Image(systemName: "sun.max.fill") + Text("오늘") + } PushNotificationListView(viewModel: PushNotificationListViewModel( fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift new file mode 100644 index 00000000..8029be81 --- /dev/null +++ b/DevLog/UI/Today/TodayView.swift @@ -0,0 +1,356 @@ +// +// TodayView.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import SwiftUI + +struct TodayView: View { + @Environment(\.diContainer) private var container: any DIContainer + @State private var router = NavigationRouter() + @State var viewModel: TodayViewModel + + var body: some View { + NavigationStack(path: $router.path) { + List { + summarySection + if viewModel.sections.isEmpty, !viewModel.state.isLoading { + emptySection + } else { + ForEach(viewModel.sections) { section in + todoSection(section.title, items: section.items) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("오늘") + .toolbar { toolbarContent } + .navigationDestination(for: Path.self) { path in + switch path { + case .detail(let todoID): + TodoDetailView(viewModel: TodoDetailViewModel( + fetchUseCase: container.resolve(FetchTodoByIDUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoID: todoID + )) + } + } + .background(NavigationBarConfigurator()) + .refreshable { viewModel.send(.refresh) } + .onAppear { viewModel.send(.onAppear) } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + ) + ) { + Button("확인", role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .overlay { + if viewModel.state.isLoading { + LoadingView() + } + } + } + } + + private var summarySection: some View { + Section { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(TodayViewModel.SummaryScope.allCases, id: \.self) { scope in + Button { + withAnimation(.easeInOut) { + viewModel.send(.setSummaryScope(scope)) + } + } label: { + SummaryCard( + title: scope.title, + value: viewModel.summaryValue(for: scope), + accentColor: scope.accentColor, + isSelected: viewModel.state.selectedSummaryScope == 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( + "보기 범위", + selection: Binding( + get: { viewModel.state.displayOptions.dueDateVisibility }, + set: { viewModel.send(.setDueDateVisibility($0)) } + ) + ) { + ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + + Toggle( + "중요 표시만", + isOn: Binding( + get: { viewModel.state.displayOptions.focusVisibility == .focusedOnly }, + set: { + viewModel.send(.setFocusVisibility($0 ? .focusedOnly : .all)) + } + ) + ) + .tint(.orange) + + if viewModel.state.displayOptions.focusVisibility == .focusedOnly { + Text("중요 표시한 Todo만 표시됩니다.") + .font(.caption) + } + } label: { + let options = viewModel.state.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 + NavigationLink(value: Path.detail(item.id)) { + TodayTodoRow(item: item) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + viewModel.send(.togglePinned(item)) + } label: { + Image(systemName: item.isPinned ? "star.slash" : "star.fill") + } + .tint(.orange) + + Button { + viewModel.send(.completeTodo(item)) + } label: { + Image(systemName: "checkmark") + } + .tint(.green) + } + } + } header: { + Text(title) + .listRowInsets(EdgeInsets()) + } + } + } + + private var emptyStateContent: EmptyStateContent { + switch viewModel.state.selectedSummaryScope { + case .all: + if viewModel.state.todos.isEmpty { + return EmptyStateContent( + title: "남아 있는 Todo가 없습니다.", + message: "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." + ) + } + return EmptyStateContent( + title: "선택한 보기 옵션에 맞는 Todo가 없습니다.", + message: "툴바에서 보기 범위를 조정하거나 전체 보기로 돌아가세요." + ) + case .focused: + return EmptyStateContent( + title: "집중할 일이 없습니다.", + message: "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있습니다." + ) + case .overdue: + return EmptyStateContent( + title: "지난 마감 Todo가 없습니다.", + message: "지금은 기한이 지난 Todo가 없습니다." + ) + case .dueSoon: + return EmptyStateContent( + title: "7일 내 일정이 없습니다.", + message: "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있습니다." + ) + } + } + + private struct EmptyStateContent { + let title: String + let message: String + } + + private enum Path: Hashable { + case detail(String) + } +} + +private extension TodayDisplayOptions.DueDateVisibility { + var title: String { + switch self { + case .all: + return "전체" + case .withDueDateOnly: + return "기한 있는 Todo만" + case .withoutDueDateOnly: + return "기한 없는 Todo만" + } + } +} + +private extension TodayViewModel.SummaryScope { + var title: String { + switch self { + case .all: + return "남은 일" + case .focused: + return "집중" + case .overdue: + return "지연" + case .dueSoon: + return "7일 내" + } + } + + 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 { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: item.kind.symbolName) + .foregroundStyle(item.kind.color) + .frame(width: 18) + Text(item.title) + .font(.headline) + .foregroundStyle(Color(.label)) + .lineLimit(1) + Spacer() + } + + HStack(spacing: 8) { + Text(item.kind.localizedName) + .font(.caption.weight(.semibold)) + .foregroundStyle(item.kind.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 { + TagLayout(lineLimit: 1) { + ForEach(item.tags, id: \.self) { tagText in + Tag(tagText, isEditing: false) + } + } + } + } + } + + 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: "기한 지남", + 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 + } +}