diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f387d66e..81c2114a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,8 +224,8 @@ jobs: schemes: "DevLogDomain DevLogData" - name: Persistence-Presentation schemes: "DevLogPersistence DevLogPresentation" - - name: WidgetCore - schemes: "DevLogWidgetCore" + - name: Widget + schemes: "DevLogWidget DevLogWidgetCore" steps: - uses: actions/checkout@v5 diff --git a/.swiftlint.yml b/.swiftlint.yml index 73c3e8bb..014709f4 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -9,6 +9,7 @@ excluded: - Application/DevLogInfra/Project.swift - Application/DevLogPersistence/Project.swift - Application/DevLogPresentation/Project.swift + - Application/DevLogWidget/Project.swift - Widget/DevLogWidgetCore/Project.swift - Widget/DevLogWidgetExtension/Project.swift diff --git a/Application/DevLogApp/Project.swift b/Application/DevLogApp/Project.swift index 992d31d7..8e89fc3b 100644 --- a/Application/DevLogApp/Project.swift +++ b/Application/DevLogApp/Project.swift @@ -34,6 +34,7 @@ let project = Project( .project(target: "DevLogPresentation", path: "../DevLogPresentation"), .project(target: "DevLogPersistence", path: "../DevLogPersistence"), .project(target: "DevLogInfra", path: "../DevLogInfra"), + .project(target: "DevLogWidget", path: "../DevLogWidget"), .project(target: "DevLogData", path: "../DevLogData"), .project(target: "DevLogDomain", path: "../DevLogDomain"), .project(target: "DevLogCore", path: "../DevLogCore"), diff --git a/Application/DevLogApp/Sources/App/Assembler/AppAssembler.swift b/Application/DevLogApp/Sources/App/Assembler/AppAssembler.swift index 07b90fcd..6a1168fb 100644 --- a/Application/DevLogApp/Sources/App/Assembler/AppAssembler.swift +++ b/Application/DevLogApp/Sources/App/Assembler/AppAssembler.swift @@ -10,11 +10,13 @@ import DevLogData import DevLogDomain import DevLogInfra import DevLogPersistence +import DevLogWidget final class AppAssembler: Assembler { private let assemblers: [Assembler] = [ PersistenceAssembler(), InfraAssembler(), + WidgetAssembler(), DataAssembler(), DomainAssembler(), AppLayerAssembler() diff --git a/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift b/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift index ac0c6f14..8dbc0bee 100644 --- a/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift +++ b/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift @@ -7,26 +7,9 @@ import DevLogCore import DevLogData -import DevLogDomain final class AppLayerAssembler: Assembler { func assemble(_ container: any DIContainer) { - container.register(WidgetSyncEventBus.self) { - WidgetSyncEventBusImpl() - } - container.register(WidgetSyncEventHandler.self) { - WidgetSyncEventHandler( - eventBus: container.resolve(WidgetSyncEventBus.self), - repository: container.resolve(TodoRepository.self), - snapshotUpdater: container.resolve(WidgetSnapshotUpdater.self) - ) - } - container.register(WidgetSessionSyncHandler.self) { - WidgetSessionSyncHandler( - authService: container.resolve(AuthService.self), - widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) - ) - } container.register(FCMTokenSyncHandler.self) { FCMTokenSyncHandler( userService: container.resolve(UserService.self) diff --git a/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift b/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift index 208d7191..c65a68d2 100644 --- a/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift +++ b/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift @@ -9,6 +9,7 @@ import UIKit import DevLogCore import DevLogData import DevLogInfra +import DevLogWidget class AppDelegate: UIResponder, UIApplicationDelegate { private let logger = Logger(category: "AppDelegate") diff --git a/Application/DevLogApp/Sources/App/DevLogApp.swift b/Application/DevLogApp/Sources/App/DevLogApp.swift index 0e68fd0b..4e8e5c36 100644 --- a/Application/DevLogApp/Sources/App/DevLogApp.swift +++ b/Application/DevLogApp/Sources/App/DevLogApp.swift @@ -10,6 +10,7 @@ import DevLogCore import DevLogData import DevLogDomain import DevLogPresentation +import DevLogWidget @main struct DevLogApp: App { diff --git a/Application/DevLogData/Sources/DataAssembler.swift b/Application/DevLogData/Sources/DataAssembler.swift index 184d9d09..bb64b47c 100644 --- a/Application/DevLogData/Sources/DataAssembler.swift +++ b/Application/DevLogData/Sources/DataAssembler.swift @@ -45,6 +45,12 @@ public final class DataAssembler: Assembler { ) } + container.register(WidgetTodoSnapshotRepository.self) { + WidgetTodoSnapshotRepositoryImpl( + repository: container.resolve(TodoRepository.self) + ) + } + container.register(TodoCategoryRepository.self) { TodoCategoryRepositoryImpl( todoCategoryService: container.resolve(TodoCategoryService.self) diff --git a/Application/DevLogData/Sources/Protocol/WidgetTodoSnapshotRepository.swift b/Application/DevLogData/Sources/Protocol/WidgetTodoSnapshotRepository.swift new file mode 100644 index 00000000..60bfac03 --- /dev/null +++ b/Application/DevLogData/Sources/Protocol/WidgetTodoSnapshotRepository.swift @@ -0,0 +1,25 @@ +// +// WidgetTodoSnapshotRepository.swift +// DevLogData +// +// Created by opfic on 6/8/26. +// + +import Foundation +import DevLogCore + +public protocol WidgetTodoSnapshotRepository { + func fetchTodayTodos( + dueDateFilter: TodoQuery.DueDateFilter, + sortTarget: TodoQuery.SortTarget, + sortOrder: TodoQuery.SortOrder, + pageSize: Int + ) async throws -> [WidgetTodoSnapshot] + + func fetchHeatmapTodos( + sortTarget: TodoQuery.SortTarget, + quarterStart: Date, + nextQuarterStart: Date, + pageSize: Int + ) async throws -> [WidgetTodoSnapshot] +} diff --git a/Application/DevLogData/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift new file mode 100644 index 00000000..b628c600 --- /dev/null +++ b/Application/DevLogData/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift @@ -0,0 +1,60 @@ +// +// WidgetTodoSnapshotRepositoryImpl.swift +// DevLogData +// +// Created by opfic on 6/8/26. +// + +import Foundation +import DevLogCore +import DevLogDomain + +final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository { + private let repository: TodoRepository + + init(repository: TodoRepository) { + self.repository = repository + } + + func fetchTodayTodos( + dueDateFilter: TodoQuery.DueDateFilter, + sortTarget: TodoQuery.SortTarget, + sortOrder: TodoQuery.SortOrder, + pageSize: Int + ) async throws -> [WidgetTodoSnapshot] { + let todoPage = try await repository.fetchTodos( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: dueDateFilter, + sortTarget: sortTarget, + sortOrder: sortOrder, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + + return todoPage.items.map(WidgetTodoSnapshot.fromDomain) + } + + func fetchHeatmapTodos( + sortTarget: TodoQuery.SortTarget, + quarterStart: Date, + nextQuarterStart: Date, + pageSize: Int + ) async throws -> [WidgetTodoSnapshot] { + let todoPage = try await repository.fetchTodos( + TodoQuery( + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: sortTarget, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + + return todoPage.items.map(WidgetTodoSnapshot.fromDomain) + } +} diff --git a/Application/DevLogData/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift new file mode 100644 index 00000000..93f0922d --- /dev/null +++ b/Application/DevLogData/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift @@ -0,0 +1,169 @@ +// +// WidgetTodoSnapshotRepositoryImplTests.swift +// DevLogDataTests +// +// Created by opfic on 6/8/26. +// + +import Foundation +import Testing +import DevLogCore +import DevLogDomain +@testable import DevLogData + +struct WidgetTodoSnapshotRepositoryImplTests { + @Test("Today 위젯 Todo 조회는 기존 TodoRepository query와 snapshot 매핑을 사용한다") + func today_위젯_todo_조회는_기존_todorepository_query와_snapshot_매핑을_사용한다() async throws { + let repositorySpy = TodoRepositorySpy() + let repository = WidgetTodoSnapshotRepositoryImpl(repository: repositorySpy) + let now = Date(timeIntervalSince1970: 100) + let todo = makeTodo(id: "today", createdAt: now, dueDate: now) + + await repositorySpy.setTodos([todo], for: .dueDate) + + let snapshots = try await repository.fetchTodayTodos( + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: 100 + ) + let queries = await repositorySpy.calledQueries() + + #expect(snapshots == [makeSnapshot(id: "today", createdAt: now, dueDate: now)]) + #expect(queries == [ + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: 100, + fetchAllPages: true + ) + ]) + } + + @Test("Heatmap 위젯 Todo 조회는 기존 TodoRepository query와 snapshot 매핑을 사용한다") + func heatmap_위젯_todo_조회는_기존_todorepository_query와_snapshot_매핑을_사용한다() async throws { + let repositorySpy = TodoRepositorySpy() + let repository = WidgetTodoSnapshotRepositoryImpl(repository: repositorySpy) + let quarterStart = Date(timeIntervalSince1970: 100) + let nextQuarterStart = Date(timeIntervalSince1970: 200) + let todo = makeTodo(id: "created", createdAt: quarterStart) + + await repositorySpy.setTodos([todo], for: .createdAt) + + let snapshots = try await repository.fetchHeatmapTodos( + sortTarget: .createdAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart, + pageSize: 100 + ) + let queries = await repositorySpy.calledQueries() + + #expect(snapshots == [makeSnapshot(id: "created", createdAt: quarterStart)]) + #expect(queries == [ + TodoQuery( + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: .createdAt, + pageSize: 100, + fetchAllPages: true + ) + ]) + } + + private func makeTodo( + id: String, + createdAt: Date, + completedAt: Date? = nil, + deletedAt: Date? = nil, + dueDate: Date? = nil + ) -> Todo { + Todo( + id: id, + isPinned: false, + isCompleted: completedAt != nil, + isChecked: false, + number: 1, + title: id, + content: "", + createdAt: createdAt, + updatedAt: createdAt, + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate, + tags: [], + category: .system(.feature) + ) + } + + private func makeSnapshot( + id: String, + createdAt: Date, + completedAt: Date? = nil, + deletedAt: Date? = nil, + dueDate: Date? = nil + ) -> WidgetTodoSnapshot { + WidgetTodoSnapshot( + id: id, + number: 1, + title: id, + isPinned: false, + createdAt: createdAt, + completedAt: completedAt, + deletedAt: deletedAt, + dueDate: dueDate + ) + } +} + +private actor TodoRepositorySpy: TodoRepository { + private var queries = [TodoQuery]() + private var todosBySortTarget = [TodoQuery.SortTarget: [Todo]]() + + func setTodos(_ todos: [Todo], for sortTarget: TodoQuery.SortTarget) { + todosBySortTarget[sortTarget] = todos + } + + func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + + return TodoPage( + items: todosBySortTarget[query.sortTarget] ?? [], + nextCursor: nil + ) + } + + func fetchTodo(_ todoId: String) async throws -> Todo { + throw TodoRepositorySpyError.unexpectedCall + } + + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { + throw TodoRepositorySpyError.unexpectedCall + } + + func upsertTodo(_ todo: Todo) async throws { + throw TodoRepositorySpyError.unexpectedCall + } + + func upsertTodo(_ todoDraft: TodoDraft) async throws { + throw TodoRepositorySpyError.unexpectedCall + } + + func deleteTodo(_ todoId: String) async throws { + throw TodoRepositorySpyError.unexpectedCall + } + + func undoDeleteTodo(_ todoId: String) async throws { + throw TodoRepositorySpyError.unexpectedCall + } + + func calledQueries() -> [TodoQuery] { + queries + } +} + +private enum TodoRepositorySpyError: Error { + case unexpectedCall +} diff --git a/Application/DevLogWidget/Project.swift b/Application/DevLogWidget/Project.swift new file mode 100644 index 00000000..f6541a00 --- /dev/null +++ b/Application/DevLogWidget/Project.swift @@ -0,0 +1,16 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.devlogFramework( + name: "DevLogWidget", + bundleId: "com.opfic.DevLog.DevLogWidget", + versionXcconfigPath: "../Shared/Version.xcconfig", + frameworkInfoPlistPath: "../Shared/InfoPlists/Framework-Info.plist", + testsInfoPlistPath: "../Shared/InfoPlists/UnitTests-Info.plist", + packages: DevLogPackages.defaultPackages, + dependencies: [ + .project(target: "DevLogData", path: "../DevLogData"), + .project(target: "DevLogCore", path: "../DevLogCore"), + ], + hasTests: true +) diff --git a/Application/DevLogWidget/Sources/.swiftlint.yml b/Application/DevLogWidget/Sources/.swiftlint.yml new file mode 100644 index 00000000..1242ffca --- /dev/null +++ b/Application/DevLogWidget/Sources/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/Application/DevLogApp/Sources/App/Handler/WidgetSessionSyncHandler.swift b/Application/DevLogWidget/Sources/Handler/WidgetSessionSyncHandler.swift similarity index 93% rename from Application/DevLogApp/Sources/App/Handler/WidgetSessionSyncHandler.swift rename to Application/DevLogWidget/Sources/Handler/WidgetSessionSyncHandler.swift index e9b99aaf..824e971b 100644 --- a/Application/DevLogApp/Sources/App/Handler/WidgetSessionSyncHandler.swift +++ b/Application/DevLogWidget/Sources/Handler/WidgetSessionSyncHandler.swift @@ -1,6 +1,6 @@ // // WidgetSessionSyncHandler.swift -// DevLog +// DevLogWidget // // Created by opfic on 6/1/26. // @@ -9,13 +9,13 @@ import Combine import Foundation import DevLogData -final class WidgetSessionSyncHandler { +public final class WidgetSessionSyncHandler { private let authService: AuthService private let widgetSyncEventBus: WidgetSyncEventBus private var hasRequestedWidgetSync = false private var cancellables = Set() - init( + public init( authService: AuthService, widgetSyncEventBus: WidgetSyncEventBus ) { diff --git a/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift b/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift new file mode 100644 index 00000000..6c09331d --- /dev/null +++ b/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift @@ -0,0 +1,32 @@ +// +// WidgetAssembler.swift +// DevLogWidget +// +// Created by opfic on 6/8/26. +// + +import DevLogCore +import DevLogData + +public final class WidgetAssembler: Assembler { + public init() { } + + public func assemble(_ container: any DIContainer) { + container.register(WidgetSyncEventBus.self) { + WidgetSyncEventBusImpl() + } + container.register(WidgetSyncEventHandler.self) { + WidgetSyncEventHandler( + eventBus: container.resolve(WidgetSyncEventBus.self), + repository: container.resolve(WidgetTodoSnapshotRepository.self), + snapshotUpdater: container.resolve(WidgetSnapshotUpdater.self) + ) + } + container.register(WidgetSessionSyncHandler.self) { + WidgetSessionSyncHandler( + authService: container.resolve(AuthService.self), + widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) + ) + } + } +} diff --git a/Application/DevLogData/Sources/Widget/WidgetSyncEventBusImpl.swift b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventBusImpl.swift similarity index 92% rename from Application/DevLogData/Sources/Widget/WidgetSyncEventBusImpl.swift rename to Application/DevLogWidget/Sources/Widget/WidgetSyncEventBusImpl.swift index 3031036f..48abc23f 100644 --- a/Application/DevLogData/Sources/Widget/WidgetSyncEventBusImpl.swift +++ b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventBusImpl.swift @@ -1,11 +1,12 @@ // // WidgetSyncEventBusImpl.swift -// DevLogData +// DevLogWidget // // Created by opfic on 4/30/26. // import Combine +import DevLogData public final class WidgetSyncEventBusImpl: WidgetSyncEventBus { private let subject = PassthroughSubject() diff --git a/Application/DevLogData/Sources/Widget/WidgetSyncEventHandler.swift b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventHandler.swift similarity index 74% rename from Application/DevLogData/Sources/Widget/WidgetSyncEventHandler.swift rename to Application/DevLogWidget/Sources/Widget/WidgetSyncEventHandler.swift index e23f8990..39a7b1ec 100644 --- a/Application/DevLogData/Sources/Widget/WidgetSyncEventHandler.swift +++ b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventHandler.swift @@ -1,6 +1,6 @@ // // WidgetSyncEventHandler.swift -// DevLogData +// DevLogWidget // // Created by opfic on 4/30/26. // @@ -8,10 +8,10 @@ import Combine import Foundation import DevLogCore -import DevLogDomain +import DevLogData public final class WidgetSyncEventHandler { - private let repository: TodoRepository + private let repository: WidgetTodoSnapshotRepository private let snapshotUpdater: WidgetSnapshotUpdater private let pageSize = 100 private let logger = Logger(category: "WidgetSyncEventHandler") @@ -19,7 +19,7 @@ public final class WidgetSyncEventHandler { public init( eventBus: WidgetSyncEventBus, - repository: TodoRepository, + repository: WidgetTodoSnapshotRepository, snapshotUpdater: WidgetSnapshotUpdater ) { self.repository = repository @@ -64,7 +64,7 @@ private extension WidgetSyncEventHandler { todosWithoutDueDate ) snapshotUpdater.updateTodaySnapshot( - todos: (todayTodosWithDueDate + todayTodosWithoutDueDate).map(WidgetTodoSnapshot.fromDomain), + todos: todayTodosWithDueDate + todayTodosWithoutDueDate, now: now ) } catch { @@ -103,9 +103,9 @@ private extension WidgetSyncEventHandler { deletedTodos ) snapshotUpdater.updateHeatmapSnapshot( - createdTodos: createdTodoItems.map(WidgetTodoSnapshot.fromDomain), - completedTodos: completedTodoItems.map(WidgetTodoSnapshot.fromDomain), - deletedTodos: deletedTodoItems.map(WidgetTodoSnapshot.fromDomain), + createdTodos: createdTodoItems, + completedTodos: completedTodoItems, + deletedTodos: deletedTodoItems, quarterStart: quarterStart, now: now ) @@ -121,39 +121,25 @@ private extension WidgetSyncEventHandler { dueDateFilter: TodoQuery.DueDateFilter, sortTarget: TodoQuery.SortTarget, sortOrder: TodoQuery.SortOrder - ) async throws -> [Todo] { - let todoPage = try await repository.fetchTodos( - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: dueDateFilter, - sortTarget: sortTarget, - sortOrder: sortOrder, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil + ) async throws -> [WidgetTodoSnapshot] { + try await repository.fetchTodayTodos( + dueDateFilter: dueDateFilter, + sortTarget: sortTarget, + sortOrder: sortOrder, + pageSize: pageSize ) - - return todoPage.items } func fetchHeatmapTodos( sortTarget: TodoQuery.SortTarget, quarterStart: Date, nextQuarterStart: Date - ) async throws -> [Todo] { - let todoPage = try await repository.fetchTodos( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: sortTarget, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil + ) async throws -> [WidgetTodoSnapshot] { + try await repository.fetchHeatmapTodos( + sortTarget: sortTarget, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart, + pageSize: pageSize ) - - return todoPage.items } } diff --git a/Application/DevLogWidget/Tests/.swiftlint.yml b/Application/DevLogWidget/Tests/.swiftlint.yml new file mode 100644 index 00000000..b2c5c38d --- /dev/null +++ b/Application/DevLogWidget/Tests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint-tests.yml diff --git a/Application/DevLogApp/Tests/App/WidgetSessionSyncHandlerTests.swift b/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift similarity index 98% rename from Application/DevLogApp/Tests/App/WidgetSessionSyncHandlerTests.swift rename to Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift index 4bcbd59e..af3b4f9a 100644 --- a/Application/DevLogApp/Tests/App/WidgetSessionSyncHandlerTests.swift +++ b/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift @@ -1,6 +1,6 @@ // // WidgetSessionSyncHandlerTests.swift -// DevLogAppTests +// DevLogWidgetTests // // Created by opfic on 6/1/26. // @@ -9,7 +9,7 @@ import Combine import Foundation import Testing import DevLogData -@testable import DevLogApp +@testable import DevLogWidget struct WidgetSessionSyncHandlerTests { @Test("로그인 세션 true 첫 진입에서만 위젯 초기 동기화를 요청한다") diff --git a/Application/DevLogData/Tests/Widget/WidgetSyncEventBusTests.swift b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventBusTests.swift similarity index 90% rename from Application/DevLogData/Tests/Widget/WidgetSyncEventBusTests.swift rename to Application/DevLogWidget/Tests/Widget/WidgetSyncEventBusTests.swift index b85610f9..188aff90 100644 --- a/Application/DevLogData/Tests/Widget/WidgetSyncEventBusTests.swift +++ b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventBusTests.swift @@ -1,13 +1,14 @@ // // WidgetSyncEventBusTests.swift -// DevLogDataTests +// DevLogWidgetTests // // Created by opfic on 4/30/26. // import Combine import Testing -@testable import DevLogData +import DevLogData +@testable import DevLogWidget struct WidgetSyncEventBusTests { @Test("WidgetSyncEventBus는 발행된 이벤트를 관찰자에게 전달한다") diff --git a/Application/DevLogData/Tests/Widget/WidgetSyncEventHandlerTests.swift b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventHandlerTests.swift similarity index 72% rename from Application/DevLogData/Tests/Widget/WidgetSyncEventHandlerTests.swift rename to Application/DevLogWidget/Tests/Widget/WidgetSyncEventHandlerTests.swift index 3a716d6e..b7f729e3 100644 --- a/Application/DevLogData/Tests/Widget/WidgetSyncEventHandlerTests.swift +++ b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventHandlerTests.swift @@ -1,6 +1,6 @@ // // WidgetSyncEventHandlerTests.swift -// DevLogDataTests +// DevLogWidgetTests // // Created by opfic on 4/30/26. // @@ -8,8 +8,8 @@ import Foundation import Testing import DevLogCore -import DevLogDomain -@testable import DevLogData +import DevLogData +@testable import DevLogWidget struct WidgetSyncEventHandlerTests { @Test("위젯 동기화 요청 이벤트는 Today와 Heatmap 스냅샷을 갱신한다") @@ -17,9 +17,9 @@ struct WidgetSyncEventHandlerTests { let calendar = Calendar.current let now = Date() let quarterStart = calendar.startOfQuarter(for: now) - let fixture = makeFixture(calendar: calendar) + let fixture = makeFixture() - await fixture.todoRepository.setTodos( + await fixture.repository.setTodos( todayTodosWithDueDate: [ makeTodo(id: "today", createdAt: now, dueDate: now) ], @@ -42,15 +42,15 @@ struct WidgetSyncEventHandlerTests { let todayUpdates = fixture.snapshotUpdater.todayUpdates let heatmapUpdates = fixture.snapshotUpdater.heatmapUpdates - let queries = await fixture.todoRepository.calledQueries() + let calls = await fixture.repository.calledCalls() #expect(todayUpdates.first?.todos.map(\.id) == ["today"]) #expect(heatmapUpdates.first?.createdTodos.map(\.id) == ["created"]) #expect(heatmapUpdates.first?.completedTodos.map(\.id) == ["completed"]) #expect(heatmapUpdates.first?.deletedTodos.map(\.id) == ["deleted"]) #expect(todayUpdates.first?.now == heatmapUpdates.first?.now) - #expect(queries.count == 5) - #expect(Set(queries.map(\.sortTarget)) == Set([ + #expect(calls.count == 5) + #expect(Set(calls.map(\.sortTarget)) == Set([ .dueDate, .updatedAt, .createdAt, @@ -65,9 +65,9 @@ struct WidgetSyncEventHandlerTests { let calendar = Calendar.current let now = Date() let quarterStart = calendar.startOfQuarter(for: now) - let fixture = makeFixture(calendar: calendar) + let fixture = makeFixture() - await fixture.todoRepository.setTodos( + await fixture.repository.setTodos( createdTodos: [ makeTodo(id: "created", createdAt: now) ], @@ -78,7 +78,7 @@ struct WidgetSyncEventHandlerTests { makeTodo(id: "deleted", createdAt: quarterStart, deletedAt: now) ] ) - await fixture.todoRepository.setFailingSortTargets([.dueDate]) + await fixture.repository.setFailingSortTargets([.dueDate]) fixture.bus.publish(.syncRequested) @@ -95,16 +95,15 @@ struct WidgetSyncEventHandlerTests { @Test("Heatmap 스냅샷 조회 실패는 Today 스냅샷 갱신을 막지 않는다") func heatmap_스냅샷_조회_실패는_today_스냅샷_갱신을_막지_않는다() async throws { - let calendar = Calendar.current let now = Date() - let fixture = makeFixture(calendar: calendar) + let fixture = makeFixture() - await fixture.todoRepository.setTodos( + await fixture.repository.setTodos( todayTodosWithDueDate: [ makeTodo(id: "today", createdAt: now, dueDate: now) ] ) - await fixture.todoRepository.setFailingSortTargets([.createdAt]) + await fixture.repository.setFailingSortTargets([.createdAt]) fixture.bus.publish(.syncRequested) @@ -117,19 +116,19 @@ struct WidgetSyncEventHandlerTests { _ = fixture.handler } - private func makeFixture(calendar: Calendar) -> Fixture { + private func makeFixture() -> Fixture { let bus = WidgetSyncEventBusImpl() - let todoRepository = WidgetSyncTodoRepositorySpy() + let repository = WidgetTodoSnapshotRepositorySpy() let snapshotUpdater = WidgetSnapshotUpdaterSpy() let handler = WidgetSyncEventHandler( eventBus: bus, - repository: todoRepository, + repository: repository, snapshotUpdater: snapshotUpdater ) return Fixture( bus: bus, - todoRepository: todoRepository, + repository: repository, snapshotUpdater: snapshotUpdater, handler: handler ) @@ -141,49 +140,46 @@ struct WidgetSyncEventHandlerTests { completedAt: Date? = nil, deletedAt: Date? = nil, dueDate: Date? = nil - ) -> Todo { - Todo( + ) -> WidgetTodoSnapshot { + WidgetTodoSnapshot( id: id, - isPinned: false, - isCompleted: completedAt != nil, - isChecked: false, number: 1, title: id, - content: "", + isPinned: false, createdAt: createdAt, - updatedAt: createdAt, completedAt: completedAt, deletedAt: deletedAt, - dueDate: dueDate, - tags: [], - category: .system(.feature) + dueDate: dueDate ) } - } private struct Fixture { let bus: WidgetSyncEventBusImpl - let todoRepository: WidgetSyncTodoRepositorySpy + let repository: WidgetTodoSnapshotRepositorySpy let snapshotUpdater: WidgetSnapshotUpdaterSpy let handler: WidgetSyncEventHandler } -private actor WidgetSyncTodoRepositorySpy: TodoRepository { - private var queries = [TodoQuery]() +private actor WidgetTodoSnapshotRepositorySpy: WidgetTodoSnapshotRepository { + struct Call { + let sortTarget: TodoQuery.SortTarget + } + + private var calls = [Call]() private var failingSortTargets = Set() - private var todayTodosWithDueDate = [Todo]() - private var todayTodosWithoutDueDate = [Todo]() - private var createdTodos = [Todo]() - private var completedTodos = [Todo]() - private var deletedTodos = [Todo]() + private var todayTodosWithDueDate = [WidgetTodoSnapshot]() + private var todayTodosWithoutDueDate = [WidgetTodoSnapshot]() + private var createdTodos = [WidgetTodoSnapshot]() + private var completedTodos = [WidgetTodoSnapshot]() + private var deletedTodos = [WidgetTodoSnapshot]() func setTodos( - todayTodosWithDueDate: [Todo] = [], - todayTodosWithoutDueDate: [Todo] = [], - createdTodos: [Todo] = [], - completedTodos: [Todo] = [], - deletedTodos: [Todo] = [] + todayTodosWithDueDate: [WidgetTodoSnapshot] = [], + todayTodosWithoutDueDate: [WidgetTodoSnapshot] = [], + createdTodos: [WidgetTodoSnapshot] = [], + completedTodos: [WidgetTodoSnapshot] = [], + deletedTodos: [WidgetTodoSnapshot] = [] ) { self.todayTodosWithDueDate = todayTodosWithDueDate self.todayTodosWithoutDueDate = todayTodosWithoutDueDate @@ -196,56 +192,54 @@ private actor WidgetSyncTodoRepositorySpy: TodoRepository { self.failingSortTargets = failingSortTargets } - func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { - queries.append(query) + func fetchTodayTodos( + dueDateFilter: TodoQuery.DueDateFilter, + sortTarget: TodoQuery.SortTarget, + sortOrder: TodoQuery.SortOrder, + pageSize: Int + ) async throws -> [WidgetTodoSnapshot] { + calls.append(Call(sortTarget: sortTarget)) - if failingSortTargets.contains(query.sortTarget) { - throw WidgetSyncTodoRepositorySpyError.fetchTodosFailed + if failingSortTargets.contains(sortTarget) { + throw WidgetTodoSnapshotRepositorySpyError.fetchTodosFailed } - let items: [Todo] - switch query.sortTarget { + switch sortTarget { case .dueDate: - items = todayTodosWithDueDate + return todayTodosWithDueDate case .updatedAt: - items = todayTodosWithoutDueDate - case .createdAt: - items = createdTodos - case .completedAt: - items = completedTodos - case .deletedAt: - items = deletedTodos + return todayTodosWithoutDueDate + case .createdAt, .completedAt, .deletedAt: + throw WidgetTodoSnapshotRepositorySpyError.unexpectedCall } - - return TodoPage(items: items, nextCursor: nil) - } - - func fetchTodo(_ todoId: String) async throws -> Todo { - throw WidgetSyncTodoRepositorySpyError.unexpectedCall } - func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { - throw WidgetSyncTodoRepositorySpyError.unexpectedCall - } - - func upsertTodo(_ todo: Todo) async throws { - throw WidgetSyncTodoRepositorySpyError.unexpectedCall - } - - func upsertTodo(_ todoDraft: TodoDraft) async throws { - throw WidgetSyncTodoRepositorySpyError.unexpectedCall - } + func fetchHeatmapTodos( + sortTarget: TodoQuery.SortTarget, + quarterStart: Date, + nextQuarterStart: Date, + pageSize: Int + ) async throws -> [WidgetTodoSnapshot] { + calls.append(Call(sortTarget: sortTarget)) - func deleteTodo(_ todoId: String) async throws { - throw WidgetSyncTodoRepositorySpyError.unexpectedCall - } + if failingSortTargets.contains(sortTarget) { + throw WidgetTodoSnapshotRepositorySpyError.fetchTodosFailed + } - func undoDeleteTodo(_ todoId: String) async throws { - throw WidgetSyncTodoRepositorySpyError.unexpectedCall + switch sortTarget { + case .createdAt: + return createdTodos + case .completedAt: + return completedTodos + case .deletedAt: + return deletedTodos + case .dueDate, .updatedAt: + throw WidgetTodoSnapshotRepositorySpyError.unexpectedCall + } } - func calledQueries() -> [TodoQuery] { - queries + func calledCalls() -> [Call] { + calls } } @@ -353,7 +347,7 @@ private final class WidgetSnapshotUpdaterSpy: WidgetSnapshotUpdater { } } -private enum WidgetSyncTodoRepositorySpyError: Error { +private enum WidgetTodoSnapshotRepositorySpyError: Error { case fetchTodosFailed case unexpectedCall } diff --git a/README.md b/README.md index 464d0bd4..31a5097a 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ MVVM을 기반으로 하되, ViewModel 상태 관리에는 MVI 형태의 단방 - + - - - - - -
- App Architecture + Tuist Module Graph
앱 아키텍처Tuist 모듈 의존성 그래프
@@ -73,14 +73,6 @@ MVVM을 기반으로 하되, ViewModel 상태 관리에는 MVI 형태의 단방
StorePattern 프로토콜
- Widget Architecture -
위젯 데이터 아키텍처
## 주요 기능 @@ -221,7 +213,8 @@ SwiftUI_DevLog/ │ ├── DevLogData/ # Repository 구현, DTO, Mapper, Data 계층 Protocol │ ├── DevLogInfra/ # Firebase, 소셜 로그인, 네트워크, 메타데이터 서비스 구현 │ ├── DevLogPersistence/ # UserDefaults, 이미지 저장소, 위젯 스냅샷 영속성 처리 -│ └── DevLogPresentation/ # SwiftUI 화면, ViewModel, Store, Coordinator +│ ├── DevLogPresentation/ # SwiftUI 화면, ViewModel, Store, Coordinator +│ └── DevLogWidget/ # 앱-위젯 브릿지, 위젯 동기화 이벤트 및 핸들러 ├── Widget/ │ ├── DevLogWidgetCore/ # 위젯 스냅샷 모델, Factory, App Group 상수 │ └── DevLogWidgetExtension/ # WidgetKit UI, Provider, Timeline diff --git a/Workspace.swift b/Workspace.swift index afbf038b..13747515 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -10,6 +10,7 @@ let workspace = Workspace( "Application/DevLogInfra", "Application/DevLogPersistence", "Application/DevLogPresentation", + "Application/DevLogWidget", "Widget/DevLogWidgetCore", "Widget/DevLogWidgetExtension", ], diff --git a/docs/graph.png b/docs/graph.png new file mode 100644 index 00000000..2371b92b Binary files /dev/null and b/docs/graph.png differ