From 0cd7c372ed1bfa2d67aed49eb8f71be585fa2360 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 17:34:04 +0900 Subject: [PATCH 01/16] =?UTF-8?q?refactor:=20shared=20=EC=83=81=EC=88=98?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Widget/Common/WidgetSnapshotStore.swift | 13 ++++--------- .../HeatmapWidgetSyncCoordinator.swift | 2 +- .../Today/TodayWidgetSyncCoordinator.swift | 2 +- DevLogWidget/Common/WidgetSnapshotStore.swift | 9 ++------- DevLogWidget/Heatmap/HeatmapWidget.swift | 2 +- DevLogWidget/Today/TodayTodoWidget.swift | 2 +- .../Widget/WidgetSharedConstantsTests.swift | 19 +++++++++++++++++++ WidgetShared/WidgetKind.swift | 13 +++++++++++++ WidgetShared/WidgetSnapshotKey.swift | 13 +++++++++++++ 9 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 DevLog_Unit/Widget/WidgetSharedConstantsTests.swift create mode 100644 WidgetShared/WidgetKind.swift create mode 100644 WidgetShared/WidgetSnapshotKey.swift diff --git a/DevLog/Widget/Common/WidgetSnapshotStore.swift b/DevLog/Widget/Common/WidgetSnapshotStore.swift index d419a1db..71abd4ff 100644 --- a/DevLog/Widget/Common/WidgetSnapshotStore.swift +++ b/DevLog/Widget/Common/WidgetSnapshotStore.swift @@ -8,11 +8,6 @@ import Foundation final class WidgetSnapshotStore { - private enum Key { - static let todaySnapshot = "Widget.today.snapshot" - static let heatmapSnapshot = "Widget.heatmap.snapshot" - } - private let store: WidgetSharedDefaultsStore private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -23,21 +18,21 @@ final class WidgetSnapshotStore { func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws { let data = try encoder.encode(snapshot) - store.setData(data, forKey: Key.todaySnapshot) + store.setData(data, forKey: WidgetSnapshotKey.today) } func loadTodaySnapshot() throws -> TodayWidgetSnapshot? { - guard let data = store.data(forKey: Key.todaySnapshot) else { return nil } + guard let data = store.data(forKey: WidgetSnapshotKey.today) else { return nil } return try decoder.decode(TodayWidgetSnapshot.self, from: data) } func saveHeatmapSnapshot(_ snapshot: HeatmapWidgetSnapshot) throws { let data = try encoder.encode(snapshot) - store.setData(data, forKey: Key.heatmapSnapshot) + store.setData(data, forKey: WidgetSnapshotKey.heatmap) } func loadHeatmapSnapshot() throws -> HeatmapWidgetSnapshot? { - guard let data = store.data(forKey: Key.heatmapSnapshot) else { return nil } + guard let data = store.data(forKey: WidgetSnapshotKey.heatmap) else { return nil } return try decoder.decode(HeatmapWidgetSnapshot.self, from: data) } } diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift index 019065cb..d86ddb50 100644 --- a/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift @@ -81,7 +81,7 @@ final class HeatmapWidgetSyncCoordinator { ) try store.saveHeatmapSnapshot(snapshot) - WidgetCenter.shared.reloadTimelines(ofKind: "HeatmapWidget") + WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.heatmap) } catch is CancellationError { logger.debug("Heatmap widget sync cancelled.") } catch { diff --git a/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift b/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift index 41904d58..f070f92e 100644 --- a/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift +++ b/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift @@ -33,7 +33,7 @@ final class TodayWidgetSyncCoordinator { do { try store.saveTodaySnapshot(todayWidgetSnapshot) - WidgetCenter.shared.reloadTimelines(ofKind: "TodayTodoWidget") + WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.todayTodo) } catch { return } diff --git a/DevLogWidget/Common/WidgetSnapshotStore.swift b/DevLogWidget/Common/WidgetSnapshotStore.swift index c2aed0d9..7cbaf4c5 100644 --- a/DevLogWidget/Common/WidgetSnapshotStore.swift +++ b/DevLogWidget/Common/WidgetSnapshotStore.swift @@ -8,11 +8,6 @@ import Foundation final class WidgetSnapshotStore { - private enum Key { - static let todaySnapshot = "Widget.today.snapshot" - static let heatmapSnapshot = "Widget.heatmap.snapshot" - } - private let store: WidgetSharedDefaultsStore private let decoder = JSONDecoder() @@ -21,12 +16,12 @@ final class WidgetSnapshotStore { } func loadTodaySnapshot() throws -> TodayWidgetSnapshot? { - guard let data = store.data(forKey: Key.todaySnapshot) else { return nil } + guard let data = store.data(forKey: WidgetSnapshotKey.today) else { return nil } return try decoder.decode(TodayWidgetSnapshot.self, from: data) } func loadHeatmapSnapshot() throws -> HeatmapWidgetSnapshot? { - guard let data = store.data(forKey: Key.heatmapSnapshot) else { return nil } + guard let data = store.data(forKey: WidgetSnapshotKey.heatmap) else { return nil } return try decoder.decode(HeatmapWidgetSnapshot.self, from: data) } } diff --git a/DevLogWidget/Heatmap/HeatmapWidget.swift b/DevLogWidget/Heatmap/HeatmapWidget.swift index 1faeec53..3f028493 100644 --- a/DevLogWidget/Heatmap/HeatmapWidget.swift +++ b/DevLogWidget/Heatmap/HeatmapWidget.swift @@ -10,7 +10,7 @@ import AppIntents import WidgetKit struct HeatmapWidget: Widget { - let kind = "HeatmapWidget" + let kind = WidgetKind.heatmap var body: some WidgetConfiguration { AppIntentConfiguration( diff --git a/DevLogWidget/Today/TodayTodoWidget.swift b/DevLogWidget/Today/TodayTodoWidget.swift index d4df2f96..67d3a204 100644 --- a/DevLogWidget/Today/TodayTodoWidget.swift +++ b/DevLogWidget/Today/TodayTodoWidget.swift @@ -10,7 +10,7 @@ import AppIntents import WidgetKit struct TodayTodoWidget: Widget { - let kind = "TodayTodoWidget" + let kind = WidgetKind.todayTodo var body: some WidgetConfiguration { AppIntentConfiguration( diff --git a/DevLog_Unit/Widget/WidgetSharedConstantsTests.swift b/DevLog_Unit/Widget/WidgetSharedConstantsTests.swift new file mode 100644 index 00000000..3a2ba3b3 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSharedConstantsTests.swift @@ -0,0 +1,19 @@ +// +// WidgetSharedConstantsTests.swift +// DevLog_Unit +// +// Created by opfic on 4/29/26. +// + +import Testing +@testable import DevLog + +struct WidgetSharedConstantsTests { + @Test("위젯 kind와 snapshot key는 공유 상수로 관리한다") + func 위젯_kind와_snapshot_key는_공유_상수로_관리한다() { + #expect(WidgetKind.todayTodo == "TodayTodoWidget") + #expect(WidgetKind.heatmap == "HeatmapWidget") + #expect(WidgetSnapshotKey.today == "Widget.today.snapshot") + #expect(WidgetSnapshotKey.heatmap == "Widget.heatmap.snapshot") + } +} diff --git a/WidgetShared/WidgetKind.swift b/WidgetShared/WidgetKind.swift new file mode 100644 index 00000000..969af0f9 --- /dev/null +++ b/WidgetShared/WidgetKind.swift @@ -0,0 +1,13 @@ +// +// WidgetKind.swift +// DevLog +// +// Created by opfic on 4/29/26. +// + +import Foundation + +enum WidgetKind { + static let todayTodo = "TodayTodoWidget" + static let heatmap = "HeatmapWidget" +} diff --git a/WidgetShared/WidgetSnapshotKey.swift b/WidgetShared/WidgetSnapshotKey.swift new file mode 100644 index 00000000..3cf46991 --- /dev/null +++ b/WidgetShared/WidgetSnapshotKey.swift @@ -0,0 +1,13 @@ +// +// WidgetSnapshotKey.swift +// DevLog +// +// Created by opfic on 4/29/26. +// + +import Foundation + +enum WidgetSnapshotKey { + static let today = "Widget.today.snapshot" + static let heatmap = "Widget.heatmap.snapshot" +} From b1264b9156941f1c5744e1f319851ebc39f2529c Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Apr 2026 17:43:13 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20WidgetSyncEvent=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Widget/Common/WidgetSyncEvent.swift | 18 +++++ DevLog_Unit/Widget/WidgetSyncEventTests.swift | 67 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 DevLog/Widget/Common/WidgetSyncEvent.swift create mode 100644 DevLog_Unit/Widget/WidgetSyncEventTests.swift diff --git a/DevLog/Widget/Common/WidgetSyncEvent.swift b/DevLog/Widget/Common/WidgetSyncEvent.swift new file mode 100644 index 00000000..28ab875b --- /dev/null +++ b/DevLog/Widget/Common/WidgetSyncEvent.swift @@ -0,0 +1,18 @@ +// +// WidgetSyncEvent.swift +// DevLog +// +// Created by opfic on 4/29/26. +// + +import Foundation + +enum WidgetSyncEvent { + case todaySnapshotChanged( + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions + ) + case heatmapSnapshotChanged( + selectedActivityKinds: Set + ) +} diff --git a/DevLog_Unit/Widget/WidgetSyncEventTests.swift b/DevLog_Unit/Widget/WidgetSyncEventTests.swift new file mode 100644 index 00000000..41e5d373 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSyncEventTests.swift @@ -0,0 +1,67 @@ +// +// WidgetSyncEventTests.swift +// DevLog_Unit +// +// Created by opfic on 4/29/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct WidgetSyncEventTests { + @Test("Today 스냅샷 변경 이벤트는 Todo 목록과 표시 옵션을 담는다") + func today_스냅샷_변경_이벤트는_Todo_목록과_표시_옵션을_담는다() throws { + let todo = try makeTodayTodoItem() + let displayOptions = TodayDisplayOptions( + dueDateVisibility: .withDueDateOnly, + focusVisibility: .focusedOnly + ) + let event = WidgetSyncEvent.todaySnapshotChanged( + todos: [todo], + displayOptions: displayOptions + ) + + guard case .todaySnapshotChanged(let todos, let options) = event else { + Issue.record("Today snapshot event expected") + return + } + #expect(todos == [todo]) + #expect(options == displayOptions) + } + + @Test("Heatmap 스냅샷 변경 이벤트는 선택된 활동 종류를 담는다") + func heatmap_스냅샷_변경_이벤트는_선택된_활동_종류를_담는다() { + let activityKinds: Set = [.created, .completed] + let event = WidgetSyncEvent.heatmapSnapshotChanged( + selectedActivityKinds: activityKinds + ) + + guard case .heatmapSnapshotChanged(let selectedActivityKinds) = event else { + Issue.record("Heatmap snapshot event expected") + return + } + #expect(selectedActivityKinds == activityKinds) + } + + private func makeTodayTodoItem() throws -> TodayTodoItem { + let todo = Todo( + id: "todo-1", + isPinned: true, + isCompleted: false, + isChecked: false, + number: 1, + title: "위젯 동기화", + content: "", + createdAt: .now, + updatedAt: .now, + completedAt: nil, + deletedAt: nil, + dueDate: .now, + tags: [], + category: .system(.feature) + ) + + return try #require(TodayTodoItem(from: todo)) + } +} From d415d79671af7160e1db9a4b9d17ac7537b2ab07 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 10:21:47 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20SyncWidgetUseCase=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/WidgetRepositoryImpl.swift | 32 +++ DevLog/Domain/Protocol/WidgetRepository.swift | 13 ++ DevLog/Widget/Common/SyncWidgetUseCase.swift | 150 ++++++++++++++ .../Widget/SyncWidgetUseCaseTests.swift | 193 ++++++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 DevLog/Data/Repository/WidgetRepositoryImpl.swift create mode 100644 DevLog/Domain/Protocol/WidgetRepository.swift create mode 100644 DevLog/Widget/Common/SyncWidgetUseCase.swift create mode 100644 DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift diff --git a/DevLog/Data/Repository/WidgetRepositoryImpl.swift b/DevLog/Data/Repository/WidgetRepositoryImpl.swift new file mode 100644 index 00000000..10801eba --- /dev/null +++ b/DevLog/Data/Repository/WidgetRepositoryImpl.swift @@ -0,0 +1,32 @@ +// +// WidgetRepositoryImpl.swift +// DevLog +// +// Created by opfic on 4/29/26. +// + +import WidgetKit + +final class WidgetRepositoryImpl: WidgetRepository { + private let store: WidgetSnapshotStore + + init(store: WidgetSnapshotStore) { + self.store = store + } + + func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws { + try store.saveTodaySnapshot(snapshot) + } + + func saveHeatmapSnapshot(_ snapshot: HeatmapWidgetSnapshot) throws { + try store.saveHeatmapSnapshot(snapshot) + } + + func reloadTodayWidget() { + WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.todayTodo) + } + + func reloadHeatmapWidget() { + WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.heatmap) + } +} diff --git a/DevLog/Domain/Protocol/WidgetRepository.swift b/DevLog/Domain/Protocol/WidgetRepository.swift new file mode 100644 index 00000000..4c89ef8a --- /dev/null +++ b/DevLog/Domain/Protocol/WidgetRepository.swift @@ -0,0 +1,13 @@ +// +// WidgetRepository.swift +// DevLog +// +// Created by opfic on 4/29/26. +// + +protocol WidgetRepository { + func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws + func saveHeatmapSnapshot(_ snapshot: HeatmapWidgetSnapshot) throws + func reloadTodayWidget() + func reloadHeatmapWidget() +} diff --git a/DevLog/Widget/Common/SyncWidgetUseCase.swift b/DevLog/Widget/Common/SyncWidgetUseCase.swift new file mode 100644 index 00000000..bc3be306 --- /dev/null +++ b/DevLog/Widget/Common/SyncWidgetUseCase.swift @@ -0,0 +1,150 @@ +// +// SyncWidgetUseCase.swift +// DevLog +// +// Created by opfic on 4/29/26. +// + +import Foundation + +final class SyncWidgetUseCase { + private let todoRepository: TodoRepository + private let widgetRepository: WidgetRepository + private let todayFactory: TodayWidgetSnapshotFactory + private let heatmapFactory: HeatmapWidgetSnapshotFactory + private let calendar: Calendar + private let logger = Logger(category: "SyncWidgetUseCase") + + init( + todoRepository: TodoRepository, + widgetRepository: WidgetRepository, + todayFactory: TodayWidgetSnapshotFactory = .init(), + heatmapFactory: HeatmapWidgetSnapshotFactory = .init(), + calendar: Calendar = .current + ) { + self.todoRepository = todoRepository + self.widgetRepository = widgetRepository + self.todayFactory = todayFactory + self.heatmapFactory = heatmapFactory + self.calendar = calendar + } + + func execute( + _ event: WidgetSyncEvent, + now: Date = Date() + ) async { + switch event { + case .todaySnapshotChanged(let todos, let displayOptions): + syncTodaySnapshot( + todos: todos, + displayOptions: displayOptions, + now: now + ) + case .heatmapSnapshotChanged(let selectedActivityKinds): + await syncHeatmapSnapshot( + selectedActivityKinds: selectedActivityKinds, + now: now + ) + } + } +} + +private extension SyncWidgetUseCase { + func syncTodaySnapshot( + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions, + now: Date + ) { + let todayWidgetSnapshot = todayFactory.makeSnapshot( + todos: todos, + displayOptions: displayOptions, + now: now + ) + + do { + try widgetRepository.saveTodaySnapshot(todayWidgetSnapshot) + widgetRepository.reloadTodayWidget() + } catch { + logger.error( + "Failed to sync today widget snapshot.", + error: error + ) + } + } + + func syncHeatmapSnapshot( + selectedActivityKinds: Set, + now: Date + ) async { + let quarterStart = startOfQuarter(for: now) + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { + return + } + + do { + async let createdTodos = fetchHeatmapTodos( + sortTarget: .createdAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + async let completedTodos = fetchHeatmapTodos( + sortTarget: .completedAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + async let deletedTodos = fetchHeatmapTodos( + sortTarget: .deletedAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + + let heatmapWidgetSnapshot = try await heatmapFactory.makeSnapshot( + createdTodos: createdTodos, + completedTodos: completedTodos, + deletedTodos: deletedTodos, + selectedActivityKinds: selectedActivityKinds, + quarterStart: quarterStart, + now: now + ) + + try widgetRepository.saveHeatmapSnapshot(heatmapWidgetSnapshot) + widgetRepository.reloadHeatmapWidget() + } catch is CancellationError { + logger.debug("Heatmap widget sync cancelled.") + } catch { + logger.error( + "Failed to sync heatmap widget snapshot.", + error: error + ) + } + } + + func fetchHeatmapTodos( + sortTarget: TodoQuery.SortTarget, + quarterStart: Date, + nextQuarterStart: Date + ) async throws -> [Todo] { + let todoPage = try await todoRepository.fetchTodos( + TodoQuery( + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: sortTarget, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + + return todoPage.items + } + + func startOfQuarter(for date: Date) -> Date { + let month = calendar.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = calendar.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return calendar.date(from: components) ?? calendar.startOfDay(for: date) + } +} diff --git a/DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift b/DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift new file mode 100644 index 00000000..ab9f6c53 --- /dev/null +++ b/DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift @@ -0,0 +1,193 @@ +// +// SyncWidgetUseCaseTests.swift +// DevLog_Unit +// +// Created by opfic on 4/29/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct SyncWidgetUseCaseTests { + @Test("Today 이벤트는 Today 스냅샷을 저장하고 Today 위젯을 갱신한다") + func today_이벤트는_Today_스냅샷을_저장하고_Today_위젯을_갱신한다() async throws { + let fixture = makeFixture() + let now = try #require(Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 29))) + let todayTodoItem = try makeTodayTodoItem(now: now) + let useCase = SyncWidgetUseCase( + todoRepository: StubTodoRepository(), + widgetRepository: fixture.widgetRepository, + calendar: Calendar.current + ) + + await useCase.execute( + .todaySnapshotChanged( + todos: [todayTodoItem], + displayOptions: .default + ), + now: now + ) + + let snapshot = try #require(fixture.widgetRepository.todaySnapshot) + #expect(snapshot.totalCount == 1) + #expect(snapshot.sections.first?.items.first?.id == todayTodoItem.id) + #expect(fixture.widgetRepository.didReloadTodayWidget) + } + + @Test("Heatmap 이벤트는 분기 Todo를 조회해 Heatmap 스냅샷을 저장하고 Heatmap 위젯을 갱신한다") + func heatmap_이벤트는_분기_Todo를_조회해_Heatmap_스냅샷을_저장하고_Heatmap_위젯을_갱신한다() async throws { + let calendar = Calendar(identifier: .gregorian) + let quarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 29))) + let todoRepository = StubTodoRepository( + todosBySortTarget: [ + .createdAt: [ + makeTodo( + id: "created", + createdAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 2))) + ) + ], + .completedAt: [ + makeTodo( + id: "completed", + createdAt: quarterStart, + completedAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))) + ) + ], + .deletedAt: [ + makeTodo( + id: "deleted", + createdAt: quarterStart, + deletedAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 4))) + ) + ] + ] + ) + let fixture = makeFixture() + let useCase = SyncWidgetUseCase( + todoRepository: todoRepository, + widgetRepository: fixture.widgetRepository, + heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar), + calendar: calendar + ) + + await useCase.execute( + .heatmapSnapshotChanged(selectedActivityKinds: [.created, .completed]), + now: now + ) + + let snapshot = try #require(fixture.widgetRepository.heatmapSnapshot) + #expect(snapshot.quarterStart == quarterStart) + #expect(snapshot.selectedActivityKindRawValues == ["created", "completed"]) + #expect(snapshot.maxCount == 1) + let queries = await todoRepository.queries + let sortTargets = Set(queries.map(\.sortTarget)) + #expect(sortTargets == [.createdAt, .completedAt, .deletedAt]) + #expect(queries.count == 3) + #expect(fixture.widgetRepository.didReloadHeatmapWidget) + } + + private func makeFixture() -> (widgetRepository: SpyWidgetRepository) { + (SpyWidgetRepository()) + } + + private func makeTodayTodoItem(now: Date) throws -> TodayTodoItem { + let todo = makeTodo( + id: "today", + createdAt: now, + dueDate: now + ) + + return try #require(TodayTodoItem(from: todo)) + } + + 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 actor StubTodoRepository: TodoRepository { + private let todosBySortTarget: [TodoQuery.SortTarget: [Todo]] + private(set) var queries = [TodoQuery]() + + init(todosBySortTarget: [TodoQuery.SortTarget: [Todo]] = [:]) { + self.todosBySortTarget = todosBySortTarget + } + + 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 TestError.unimplemented + } + + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { + throw TestError.unimplemented + } + + func upsertTodo(_ todo: Todo) async throws { + throw TestError.unimplemented + } + + func deleteTodo(_ todoId: String) async throws { + throw TestError.unimplemented + } + + func undoDeleteTodo(_ todoId: String) async throws { + throw TestError.unimplemented + } +} + +private final class SpyWidgetRepository: WidgetRepository { + private(set) var todaySnapshot: TodayWidgetSnapshot? + private(set) var heatmapSnapshot: HeatmapWidgetSnapshot? + private(set) var didReloadTodayWidget = false + private(set) var didReloadHeatmapWidget = false + + func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws { + todaySnapshot = snapshot + } + + func saveHeatmapSnapshot(_ snapshot: HeatmapWidgetSnapshot) throws { + heatmapSnapshot = snapshot + } + + func reloadTodayWidget() { + didReloadTodayWidget = true + } + + func reloadHeatmapWidget() { + didReloadHeatmapWidget = true + } +} + +private enum TestError: Error { + case unimplemented +} From 8e69e9b5880b6d5ee2f8823b01fba2c2bec3d0d6 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 11:26:49 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20WidgetSnapshotUpdater=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/WidgetRepositoryImpl.swift | 32 --- DevLog/Domain/Protocol/WidgetRepository.swift | 13 -- .../Persistence/WidgetSnapshotUpdater.swift | 88 ++++++++ DevLog/Widget/Common/SyncWidgetUseCase.swift | 150 -------------- .../Widget/SyncWidgetUseCaseTests.swift | 193 ------------------ .../Widget/WidgetSnapshotUpdaterTests.swift | 120 +++++++++++ 6 files changed, 208 insertions(+), 388 deletions(-) delete mode 100644 DevLog/Data/Repository/WidgetRepositoryImpl.swift delete mode 100644 DevLog/Domain/Protocol/WidgetRepository.swift create mode 100644 DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift delete mode 100644 DevLog/Widget/Common/SyncWidgetUseCase.swift delete mode 100644 DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift create mode 100644 DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift diff --git a/DevLog/Data/Repository/WidgetRepositoryImpl.swift b/DevLog/Data/Repository/WidgetRepositoryImpl.swift deleted file mode 100644 index 10801eba..00000000 --- a/DevLog/Data/Repository/WidgetRepositoryImpl.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// WidgetRepositoryImpl.swift -// DevLog -// -// Created by opfic on 4/29/26. -// - -import WidgetKit - -final class WidgetRepositoryImpl: WidgetRepository { - private let store: WidgetSnapshotStore - - init(store: WidgetSnapshotStore) { - self.store = store - } - - func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws { - try store.saveTodaySnapshot(snapshot) - } - - func saveHeatmapSnapshot(_ snapshot: HeatmapWidgetSnapshot) throws { - try store.saveHeatmapSnapshot(snapshot) - } - - func reloadTodayWidget() { - WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.todayTodo) - } - - func reloadHeatmapWidget() { - WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.heatmap) - } -} diff --git a/DevLog/Domain/Protocol/WidgetRepository.swift b/DevLog/Domain/Protocol/WidgetRepository.swift deleted file mode 100644 index 4c89ef8a..00000000 --- a/DevLog/Domain/Protocol/WidgetRepository.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// WidgetRepository.swift -// DevLog -// -// Created by opfic on 4/29/26. -// - -protocol WidgetRepository { - func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws - func saveHeatmapSnapshot(_ snapshot: HeatmapWidgetSnapshot) throws - func reloadTodayWidget() - func reloadHeatmapWidget() -} diff --git a/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift b/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift new file mode 100644 index 00000000..8a4c5e28 --- /dev/null +++ b/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift @@ -0,0 +1,88 @@ +// +// WidgetSnapshotUpdater.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Foundation +import WidgetKit + +final class WidgetSnapshotUpdater { + private let snapshotStore: WidgetSnapshotStore + private let todayFactory: TodayWidgetSnapshotFactory + private let heatmapFactory: HeatmapWidgetSnapshotFactory + private let calendar: Calendar + private let logger = Logger(category: "WidgetSnapshotUpdater") + + init( + snapshotStore: WidgetSnapshotStore, + todayFactory: TodayWidgetSnapshotFactory = .init(), + heatmapFactory: HeatmapWidgetSnapshotFactory = .init(), + calendar: Calendar = .current + ) { + self.snapshotStore = snapshotStore + self.todayFactory = todayFactory + self.heatmapFactory = heatmapFactory + self.calendar = calendar + } + + func updateTodaySnapshot( + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions, + now: Date = Date() + ) { + let todayWidgetSnapshot = todayFactory.makeSnapshot( + todos: todos, + displayOptions: displayOptions, + now: now + ) + + do { + try snapshotStore.saveTodaySnapshot(todayWidgetSnapshot) + WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.todayTodo) + } catch { + logger.error( + "Failed to update today widget snapshot.", + error: error + ) + } + } + + func updateHeatmapSnapshot( + createdTodos: [Todo], + completedTodos: [Todo], + deletedTodos: [Todo], + selectedActivityKinds: Set, + quarterStart: Date, + now: Date = Date() + ) { + let heatmapWidgetSnapshot = heatmapFactory.makeSnapshot( + createdTodos: createdTodos, + completedTodos: completedTodos, + deletedTodos: deletedTodos, + selectedActivityKinds: selectedActivityKinds, + quarterStart: quarterStart, + now: now + ) + + do { + try snapshotStore.saveHeatmapSnapshot(heatmapWidgetSnapshot) + WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.heatmap) + } catch { + logger.error( + "Failed to update heatmap widget snapshot.", + error: error + ) + } + } + + func startOfQuarter(for date: Date) -> Date { + let month = calendar.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = calendar.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return calendar.date(from: components) ?? calendar.startOfDay(for: date) + } +} diff --git a/DevLog/Widget/Common/SyncWidgetUseCase.swift b/DevLog/Widget/Common/SyncWidgetUseCase.swift deleted file mode 100644 index bc3be306..00000000 --- a/DevLog/Widget/Common/SyncWidgetUseCase.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// SyncWidgetUseCase.swift -// DevLog -// -// Created by opfic on 4/29/26. -// - -import Foundation - -final class SyncWidgetUseCase { - private let todoRepository: TodoRepository - private let widgetRepository: WidgetRepository - private let todayFactory: TodayWidgetSnapshotFactory - private let heatmapFactory: HeatmapWidgetSnapshotFactory - private let calendar: Calendar - private let logger = Logger(category: "SyncWidgetUseCase") - - init( - todoRepository: TodoRepository, - widgetRepository: WidgetRepository, - todayFactory: TodayWidgetSnapshotFactory = .init(), - heatmapFactory: HeatmapWidgetSnapshotFactory = .init(), - calendar: Calendar = .current - ) { - self.todoRepository = todoRepository - self.widgetRepository = widgetRepository - self.todayFactory = todayFactory - self.heatmapFactory = heatmapFactory - self.calendar = calendar - } - - func execute( - _ event: WidgetSyncEvent, - now: Date = Date() - ) async { - switch event { - case .todaySnapshotChanged(let todos, let displayOptions): - syncTodaySnapshot( - todos: todos, - displayOptions: displayOptions, - now: now - ) - case .heatmapSnapshotChanged(let selectedActivityKinds): - await syncHeatmapSnapshot( - selectedActivityKinds: selectedActivityKinds, - now: now - ) - } - } -} - -private extension SyncWidgetUseCase { - func syncTodaySnapshot( - todos: [TodayTodoItem], - displayOptions: TodayDisplayOptions, - now: Date - ) { - let todayWidgetSnapshot = todayFactory.makeSnapshot( - todos: todos, - displayOptions: displayOptions, - now: now - ) - - do { - try widgetRepository.saveTodaySnapshot(todayWidgetSnapshot) - widgetRepository.reloadTodayWidget() - } catch { - logger.error( - "Failed to sync today widget snapshot.", - error: error - ) - } - } - - func syncHeatmapSnapshot( - selectedActivityKinds: Set, - now: Date - ) async { - let quarterStart = startOfQuarter(for: now) - guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { - return - } - - do { - async let createdTodos = fetchHeatmapTodos( - sortTarget: .createdAt, - quarterStart: quarterStart, - nextQuarterStart: nextQuarterStart - ) - async let completedTodos = fetchHeatmapTodos( - sortTarget: .completedAt, - quarterStart: quarterStart, - nextQuarterStart: nextQuarterStart - ) - async let deletedTodos = fetchHeatmapTodos( - sortTarget: .deletedAt, - quarterStart: quarterStart, - nextQuarterStart: nextQuarterStart - ) - - let heatmapWidgetSnapshot = try await heatmapFactory.makeSnapshot( - createdTodos: createdTodos, - completedTodos: completedTodos, - deletedTodos: deletedTodos, - selectedActivityKinds: selectedActivityKinds, - quarterStart: quarterStart, - now: now - ) - - try widgetRepository.saveHeatmapSnapshot(heatmapWidgetSnapshot) - widgetRepository.reloadHeatmapWidget() - } catch is CancellationError { - logger.debug("Heatmap widget sync cancelled.") - } catch { - logger.error( - "Failed to sync heatmap widget snapshot.", - error: error - ) - } - } - - func fetchHeatmapTodos( - sortTarget: TodoQuery.SortTarget, - quarterStart: Date, - nextQuarterStart: Date - ) async throws -> [Todo] { - let todoPage = try await todoRepository.fetchTodos( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: sortTarget, - pageSize: 100, - fetchAllPages: true - ), - cursor: nil - ) - - return todoPage.items - } - - func startOfQuarter(for date: Date) -> Date { - let month = calendar.component(.month, from: date) - let startMonth = ((month - 1) / 3) * 3 + 1 - var components = calendar.dateComponents([.year], from: date) - components.month = startMonth - components.day = 1 - return calendar.date(from: components) ?? calendar.startOfDay(for: date) - } -} diff --git a/DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift b/DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift deleted file mode 100644 index ab9f6c53..00000000 --- a/DevLog_Unit/Widget/SyncWidgetUseCaseTests.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// SyncWidgetUseCaseTests.swift -// DevLog_Unit -// -// Created by opfic on 4/29/26. -// - -import Foundation -import Testing -@testable import DevLog - -struct SyncWidgetUseCaseTests { - @Test("Today 이벤트는 Today 스냅샷을 저장하고 Today 위젯을 갱신한다") - func today_이벤트는_Today_스냅샷을_저장하고_Today_위젯을_갱신한다() async throws { - let fixture = makeFixture() - let now = try #require(Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 29))) - let todayTodoItem = try makeTodayTodoItem(now: now) - let useCase = SyncWidgetUseCase( - todoRepository: StubTodoRepository(), - widgetRepository: fixture.widgetRepository, - calendar: Calendar.current - ) - - await useCase.execute( - .todaySnapshotChanged( - todos: [todayTodoItem], - displayOptions: .default - ), - now: now - ) - - let snapshot = try #require(fixture.widgetRepository.todaySnapshot) - #expect(snapshot.totalCount == 1) - #expect(snapshot.sections.first?.items.first?.id == todayTodoItem.id) - #expect(fixture.widgetRepository.didReloadTodayWidget) - } - - @Test("Heatmap 이벤트는 분기 Todo를 조회해 Heatmap 스냅샷을 저장하고 Heatmap 위젯을 갱신한다") - func heatmap_이벤트는_분기_Todo를_조회해_Heatmap_스냅샷을_저장하고_Heatmap_위젯을_갱신한다() async throws { - let calendar = Calendar(identifier: .gregorian) - let quarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) - let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 29))) - let todoRepository = StubTodoRepository( - todosBySortTarget: [ - .createdAt: [ - makeTodo( - id: "created", - createdAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 2))) - ) - ], - .completedAt: [ - makeTodo( - id: "completed", - createdAt: quarterStart, - completedAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))) - ) - ], - .deletedAt: [ - makeTodo( - id: "deleted", - createdAt: quarterStart, - deletedAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 4))) - ) - ] - ] - ) - let fixture = makeFixture() - let useCase = SyncWidgetUseCase( - todoRepository: todoRepository, - widgetRepository: fixture.widgetRepository, - heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar), - calendar: calendar - ) - - await useCase.execute( - .heatmapSnapshotChanged(selectedActivityKinds: [.created, .completed]), - now: now - ) - - let snapshot = try #require(fixture.widgetRepository.heatmapSnapshot) - #expect(snapshot.quarterStart == quarterStart) - #expect(snapshot.selectedActivityKindRawValues == ["created", "completed"]) - #expect(snapshot.maxCount == 1) - let queries = await todoRepository.queries - let sortTargets = Set(queries.map(\.sortTarget)) - #expect(sortTargets == [.createdAt, .completedAt, .deletedAt]) - #expect(queries.count == 3) - #expect(fixture.widgetRepository.didReloadHeatmapWidget) - } - - private func makeFixture() -> (widgetRepository: SpyWidgetRepository) { - (SpyWidgetRepository()) - } - - private func makeTodayTodoItem(now: Date) throws -> TodayTodoItem { - let todo = makeTodo( - id: "today", - createdAt: now, - dueDate: now - ) - - return try #require(TodayTodoItem(from: todo)) - } - - 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 actor StubTodoRepository: TodoRepository { - private let todosBySortTarget: [TodoQuery.SortTarget: [Todo]] - private(set) var queries = [TodoQuery]() - - init(todosBySortTarget: [TodoQuery.SortTarget: [Todo]] = [:]) { - self.todosBySortTarget = todosBySortTarget - } - - 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 TestError.unimplemented - } - - func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { - throw TestError.unimplemented - } - - func upsertTodo(_ todo: Todo) async throws { - throw TestError.unimplemented - } - - func deleteTodo(_ todoId: String) async throws { - throw TestError.unimplemented - } - - func undoDeleteTodo(_ todoId: String) async throws { - throw TestError.unimplemented - } -} - -private final class SpyWidgetRepository: WidgetRepository { - private(set) var todaySnapshot: TodayWidgetSnapshot? - private(set) var heatmapSnapshot: HeatmapWidgetSnapshot? - private(set) var didReloadTodayWidget = false - private(set) var didReloadHeatmapWidget = false - - func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws { - todaySnapshot = snapshot - } - - func saveHeatmapSnapshot(_ snapshot: HeatmapWidgetSnapshot) throws { - heatmapSnapshot = snapshot - } - - func reloadTodayWidget() { - didReloadTodayWidget = true - } - - func reloadHeatmapWidget() { - didReloadHeatmapWidget = true - } -} - -private enum TestError: Error { - case unimplemented -} diff --git a/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift new file mode 100644 index 00000000..02dd0615 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift @@ -0,0 +1,120 @@ +// +// WidgetSnapshotUpdaterTests.swift +// DevLog_Unit +// +// Created by opfic on 4/30/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct WidgetSnapshotUpdaterTests { + @Test("Today 스냅샷 갱신은 Today 스냅샷을 저장한다") + func today_스냅샷_갱신은_Today_스냅샷을_저장한다() throws { + let fixture = makeFixture() + let now = try #require(Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 30))) + let todo = try makeTodayTodoItem(now: now) + + fixture.updater.updateTodaySnapshot( + todos: [todo], + displayOptions: .default, + now: now + ) + + let snapshot = try #require(try fixture.snapshotStore.loadTodaySnapshot()) + #expect(snapshot.totalCount == 1) + #expect(snapshot.sections.first?.items.first?.id == todo.id) + } + + @Test("Heatmap 스냅샷 갱신은 Heatmap 스냅샷을 저장한다") + func heatmap_스냅샷_갱신은_Heatmap_스냅샷을_저장한다() throws { + let calendar = Calendar(identifier: .gregorian) + let quarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 30))) + let fixture = makeFixture(calendar: calendar) + + fixture.updater.updateHeatmapSnapshot( + createdTodos: [ + makeTodo( + id: "created", + createdAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 2))) + ) + ], + completedTodos: [ + makeTodo( + id: "completed", + createdAt: quarterStart, + completedAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))) + ) + ], + deletedTodos: [ + makeTodo( + id: "deleted", + createdAt: quarterStart, + deletedAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 4))) + ) + ], + selectedActivityKinds: [.created, .completed], + quarterStart: quarterStart, + now: now + ) + + let snapshot = try #require(try fixture.snapshotStore.loadHeatmapSnapshot()) + #expect(snapshot.quarterStart == quarterStart) + #expect(snapshot.selectedActivityKindRawValues == ["created", "completed"]) + #expect(snapshot.maxCount == 1) + } + + private func makeFixture( + calendar: Calendar = .current + ) -> (updater: WidgetSnapshotUpdater, snapshotStore: WidgetSnapshotStore) { + let suiteName = "WidgetSnapshotUpdaterTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard + userDefaults.removePersistentDomain(forName: suiteName) + let snapshotStore = WidgetSnapshotStore( + store: WidgetSharedDefaultsStore(userDefaults: userDefaults) + ) + let updater = WidgetSnapshotUpdater( + snapshotStore: snapshotStore, + heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar), + calendar: calendar + ) + return (updater, snapshotStore) + } + + private func makeTodayTodoItem(now: Date) throws -> TodayTodoItem { + let todo = makeTodo( + id: "today", + createdAt: now, + dueDate: now + ) + + return try #require(TodayTodoItem(from: todo)) + } + + 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) + ) + } +} From 28097ad32018be3deb7a2cbbcaaf7c8330eed22e Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 11:27:43 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=EC=9C=84=EC=A0=AF=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20DI=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/PersistenceAssembler.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/DevLog/App/Assembler/PersistenceAssembler.swift b/DevLog/App/Assembler/PersistenceAssembler.swift index 70bd6ced..35e973cf 100644 --- a/DevLog/App/Assembler/PersistenceAssembler.swift +++ b/DevLog/App/Assembler/PersistenceAssembler.swift @@ -18,5 +18,21 @@ final class PersistenceAssembler: Assembler { container.register(WebPageImageStore.self) { WebPageImageStore() } + + container.register(WidgetSharedDefaultsStore.self) { + WidgetSharedDefaultsStore() + } + + container.register(WidgetSnapshotStore.self) { + WidgetSnapshotStore( + store: container.resolve(WidgetSharedDefaultsStore.self) + ) + } + + container.register(WidgetSnapshotUpdater.self) { + WidgetSnapshotUpdater( + snapshotStore: container.resolve(WidgetSnapshotStore.self) + ) + } } } From ee213e8e24798b058f921e9a5df2e66375485b47 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 12:18:07 +0900 Subject: [PATCH 06/16] =?UTF-8?q?refactor:=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EA=B0=B1=EC=8B=A0=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DataAssembler.swift | 6 +- .../App/Assembler/PersistenceAssembler.swift | 7 +- .../Data/Repository/TodoRepositoryImpl.swift | 130 +++++++++++++++++- .../UserPreferencesRepositoryImpl.swift | 27 ++-- .../WidgetSnapshotPreferenceStore.swift | 61 ++++++++ .../Persistence/WidgetSnapshotUpdater.swift | 31 +++++ .../Widget/WidgetSnapshotUpdaterTests.swift | 4 + 7 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 DevLog/Storage/Persistence/WidgetSnapshotPreferenceStore.swift diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index aaba3440..99a2f6c6 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -29,7 +29,8 @@ final class DataAssembler: Assembler { container.register(TodoRepository.self) { TodoRepositoryImpl( todoService: container.resolve(TodoService.self), - todoCategoryService: container.resolve(TodoCategoryService.self) + todoCategoryService: container.resolve(TodoCategoryService.self), + widgetSnapshotUpdater: container.resolve(WidgetSnapshotUpdater.self) ) } @@ -96,7 +97,8 @@ final class DataAssembler: Assembler { container.register(UserPreferencesRepository.self) { UserPreferencesRepositoryImpl( store: container.resolve(UserDefaultsStore.self), - themeStore: container.resolve(ThemeStore.self) + themeStore: container.resolve(ThemeStore.self), + widgetSnapshotPreferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self) ) } } diff --git a/DevLog/App/Assembler/PersistenceAssembler.swift b/DevLog/App/Assembler/PersistenceAssembler.swift index 35e973cf..2eea5242 100644 --- a/DevLog/App/Assembler/PersistenceAssembler.swift +++ b/DevLog/App/Assembler/PersistenceAssembler.swift @@ -29,9 +29,14 @@ final class PersistenceAssembler: Assembler { ) } + container.register(WidgetSnapshotPreferenceStore.self) { + WidgetSnapshotPreferenceStore() + } + container.register(WidgetSnapshotUpdater.self) { WidgetSnapshotUpdater( - snapshotStore: container.resolve(WidgetSnapshotStore.self) + snapshotStore: container.resolve(WidgetSnapshotStore.self), + preferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self) ) } } diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index ec3a49d3..497f2f05 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -10,13 +10,19 @@ import Foundation final class TodoRepositoryImpl: TodoRepository { private let todoService: TodoService private let todoCategoryService: TodoCategoryService + private let widgetSnapshotUpdater: WidgetSnapshotUpdater + private let calendar = Calendar.current + private let pageSize = 100 + private let logger = Logger(category: "TodoRepositoryImpl") init( todoService: TodoService, - todoCategoryService: TodoCategoryService + todoCategoryService: TodoCategoryService, + widgetSnapshotUpdater: WidgetSnapshotUpdater ) { self.todoService = todoService self.todoCategoryService = todoCategoryService + self.widgetSnapshotUpdater = widgetSnapshotUpdater } func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { @@ -89,18 +95,140 @@ final class TodoRepositoryImpl: TodoRepository { func upsertTodo(_ todo: Todo) async throws { let request = TodoRequest.fromDomain(todo) try await todoService.upsertTodo(request: request) + updateWidgetSnapshots() } func deleteTodo(_ todoId: String) async throws { try await todoService.deleteTodo(todoId: todoId) + updateWidgetSnapshots() } func undoDeleteTodo(_ todoId: String) async throws { try await todoService.undoDeleteTodo(todoId: todoId) + updateWidgetSnapshots() } } private extension TodoRepositoryImpl { + func updateWidgetSnapshots() { + Task { [weak self] in + guard let self else { return } + async let todaySnapshot: Void = updateTodayWidgetSnapshot() + async let heatmapSnapshot: Void = updateHeatmapWidgetSnapshot() + _ = await (todaySnapshot, heatmapSnapshot) + } + } + + func updateTodayWidgetSnapshot() async { + do { + async let todosWithDueDate = fetchTodayTodos( + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest + ) + async let todosWithoutDueDate = fetchTodayTodos( + dueDateFilter: .withoutDueDate, + sortTarget: .updatedAt, + sortOrder: .latest + ) + let (todayTodosWithDueDate, todayTodosWithoutDueDate) = try await ( + todosWithDueDate, + todosWithoutDueDate + ) + widgetSnapshotUpdater.updateTodaySnapshot( + todos: todayTodosWithDueDate + todayTodosWithoutDueDate + ) + } catch { + logger.error( + "Failed to fetch today widget snapshot data.", + error: error + ) + } + } + + func updateHeatmapWidgetSnapshot() async { + let now = Date() + let quarterStart = widgetSnapshotUpdater.startOfQuarter(for: now) + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { + return + } + + do { + async let createdTodos = fetchHeatmapTodos( + sortTarget: .createdAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + async let completedTodos = fetchHeatmapTodos( + sortTarget: .completedAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + async let deletedTodos = fetchHeatmapTodos( + sortTarget: .deletedAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + let (createdTodoItems, completedTodoItems, deletedTodoItems) = try await ( + createdTodos, + completedTodos, + deletedTodos + ) + widgetSnapshotUpdater.updateHeatmapSnapshot( + createdTodos: createdTodoItems, + completedTodos: completedTodoItems, + deletedTodos: deletedTodoItems, + quarterStart: quarterStart, + now: now + ) + } catch { + logger.error( + "Failed to fetch heatmap widget snapshot data.", + error: error + ) + } + } + + func fetchTodayTodos( + dueDateFilter: TodoQuery.DueDateFilter, + sortTarget: TodoQuery.SortTarget, + sortOrder: TodoQuery.SortOrder + ) async throws -> [TodayTodoItem] { + let todoPage = try await fetchTodos( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: dueDateFilter, + sortTarget: sortTarget, + sortOrder: sortOrder, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + + return todoPage.items.compactMap { TodayTodoItem(from: $0) } + } + + func fetchHeatmapTodos( + sortTarget: TodoQuery.SortTarget, + quarterStart: Date, + nextQuarterStart: Date + ) async throws -> [Todo] { + let todoPage = try await fetchTodos( + TodoQuery( + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: sortTarget, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + + return todoPage.items + } + func resolve( _ response: TodoResponse, userTodoCategories: [UserTodoCategory] diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift index c8bbdd1a..4b8c2ade 100644 --- a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -15,20 +15,20 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { static let pushSortOrder = "PushNotification.sortOption" static let pushTimeFilter = "PushNotification.timeFilter" static let pushUnreadOnly = "PushNotification.showUnreadOnly" - static let heatmapActivityTypes = "Profile.heatmap.activityTypes" - static let todayDueDateVisibility = "Today.dueDateVisibility" - static let todayFocusVisibility = "Today.focusVisibility" } private let store: UserDefaultsStore private let themeStore: ThemeStore + private let widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore init( store: UserDefaultsStore, - themeStore: ThemeStore + themeStore: ThemeStore, + widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore ) { self.store = store self.themeStore = themeStore + self.widgetSnapshotPreferenceStore = widgetSnapshotPreferenceStore themeStore.send(systemTheme()) } @@ -85,29 +85,18 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { } func heatmapActivityTypes() -> [String] { - store.stringArray(forKey: Key.heatmapActivityTypes) + widgetSnapshotPreferenceStore.heatmapActivityTypes() } func setHeatmapActivityTypes(_ activityTypes: [String]) { - store.setStringArray(activityTypes, forKey: Key.heatmapActivityTypes) + widgetSnapshotPreferenceStore.setHeatmapActivityTypes(activityTypes) } 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 - ) + widgetSnapshotPreferenceStore.todayDisplayOptions() } func setTodayDisplayOptions(_ options: TodayDisplayOptions) { - store.setString(options.dueDateVisibility.rawValue, forKey: Key.todayDueDateVisibility) - store.setString(options.focusVisibility.rawValue, forKey: Key.todayFocusVisibility) + widgetSnapshotPreferenceStore.setTodayDisplayOptions(options) } } diff --git a/DevLog/Storage/Persistence/WidgetSnapshotPreferenceStore.swift b/DevLog/Storage/Persistence/WidgetSnapshotPreferenceStore.swift new file mode 100644 index 00000000..56094f79 --- /dev/null +++ b/DevLog/Storage/Persistence/WidgetSnapshotPreferenceStore.swift @@ -0,0 +1,61 @@ +// +// WidgetSnapshotPreferenceStore.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Foundation + +final class WidgetSnapshotPreferenceStore { + private enum Key { + static let heatmapActivityTypes = "Profile.heatmap.activityTypes" + static let todayDueDateVisibility = "Today.dueDateVisibility" + static let todayFocusVisibility = "Today.focusVisibility" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func heatmapActivityTypes() -> [String] { + userDefaults.stringArray(forKey: Key.heatmapActivityTypes) ?? [] + } + + func setHeatmapActivityTypes(_ activityTypes: [String]) { + userDefaults.set(activityTypes, forKey: Key.heatmapActivityTypes) + } + + func selectedActivityKinds() -> Set { + let selectedActivityKinds = Set( + heatmapActivityTypes().compactMap(ActivityKind.init(rawValue:)) + ) + let selectableActivityKinds: [ActivityKind] = [.created, .completed, .deleted] + let normalizedActivityKinds = Set( + selectableActivityKinds.filter { selectedActivityKinds.contains($0) } + ) + + return normalizedActivityKinds.isEmpty ? Set(selectableActivityKinds) : normalizedActivityKinds + } + + func todayDisplayOptions() -> TodayDisplayOptions { + let dueDateVisibilityRawValue = userDefaults.string(forKey: Key.todayDueDateVisibility) + let focusVisibilityRawValue = userDefaults.string(forKey: Key.todayFocusVisibility) + + return TodayDisplayOptions( + dueDateVisibility: TodayDisplayOptions.DueDateVisibility( + rawValue: dueDateVisibilityRawValue ?? "" + ) ?? .all, + focusVisibility: TodayDisplayOptions.FocusVisibility( + rawValue: focusVisibilityRawValue ?? "" + ) ?? .all + ) + } + + func setTodayDisplayOptions(_ options: TodayDisplayOptions) { + userDefaults.set(options.dueDateVisibility.rawValue, forKey: Key.todayDueDateVisibility) + userDefaults.set(options.focusVisibility.rawValue, forKey: Key.todayFocusVisibility) + } +} diff --git a/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift b/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift index 8a4c5e28..1418878d 100644 --- a/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift +++ b/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift @@ -10,6 +10,7 @@ import WidgetKit final class WidgetSnapshotUpdater { private let snapshotStore: WidgetSnapshotStore + private let preferenceStore: WidgetSnapshotPreferenceStore private let todayFactory: TodayWidgetSnapshotFactory private let heatmapFactory: HeatmapWidgetSnapshotFactory private let calendar: Calendar @@ -17,16 +18,29 @@ final class WidgetSnapshotUpdater { init( snapshotStore: WidgetSnapshotStore, + preferenceStore: WidgetSnapshotPreferenceStore, todayFactory: TodayWidgetSnapshotFactory = .init(), heatmapFactory: HeatmapWidgetSnapshotFactory = .init(), calendar: Calendar = .current ) { self.snapshotStore = snapshotStore + self.preferenceStore = preferenceStore self.todayFactory = todayFactory self.heatmapFactory = heatmapFactory self.calendar = calendar } + func updateTodaySnapshot( + todos: [TodayTodoItem], + now: Date = Date() + ) { + updateTodaySnapshot( + todos: todos, + displayOptions: preferenceStore.todayDisplayOptions(), + now: now + ) + } + func updateTodaySnapshot( todos: [TodayTodoItem], displayOptions: TodayDisplayOptions, @@ -49,6 +63,23 @@ final class WidgetSnapshotUpdater { } } + func updateHeatmapSnapshot( + createdTodos: [Todo], + completedTodos: [Todo], + deletedTodos: [Todo], + quarterStart: Date, + now: Date = Date() + ) { + updateHeatmapSnapshot( + createdTodos: createdTodos, + completedTodos: completedTodos, + deletedTodos: deletedTodos, + selectedActivityKinds: preferenceStore.selectedActivityKinds(), + quarterStart: quarterStart, + now: now + ) + } + func updateHeatmapSnapshot( createdTodos: [Todo], completedTodos: [Todo], diff --git a/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift index 02dd0615..e5720536 100644 --- a/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift +++ b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift @@ -75,8 +75,12 @@ struct WidgetSnapshotUpdaterTests { let snapshotStore = WidgetSnapshotStore( store: WidgetSharedDefaultsStore(userDefaults: userDefaults) ) + let preferenceStore = WidgetSnapshotPreferenceStore( + userDefaults: userDefaults + ) let updater = WidgetSnapshotUpdater( snapshotStore: snapshotStore, + preferenceStore: preferenceStore, heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar), calendar: calendar ) From 981956398bfe867ccde1125615d656f2c8a3ab4d Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 14:18:47 +0900 Subject: [PATCH 07/16] =?UTF-8?q?test:=20Heatmap=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift index 0fd198e3..d6284655 100644 --- a/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift +++ b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift @@ -138,7 +138,7 @@ struct HeatmapWidgetSnapshotFactoryTests { .flatMap(\.weeks) .flatMap(\.days) .first { day in - calendar.isDate(day.date, inSameDayAs: targetDate) + day.isVisible && calendar.isDate(day.date, inSameDayAs: targetDate) } } From ce975a32da552725d84a55415458f97f4660a4e6 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 16:44:28 +0900 Subject: [PATCH 08/16] =?UTF-8?q?refactor:=20WidgetSyncEvent=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9B=90=EC=9D=B8=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Widget/Common/WidgetSyncEvent.swift | 14 ++--- DevLog_Unit/Widget/WidgetSyncEventTests.swift | 58 ++----------------- 2 files changed, 9 insertions(+), 63 deletions(-) diff --git a/DevLog/Widget/Common/WidgetSyncEvent.swift b/DevLog/Widget/Common/WidgetSyncEvent.swift index 28ab875b..24539d48 100644 --- a/DevLog/Widget/Common/WidgetSyncEvent.swift +++ b/DevLog/Widget/Common/WidgetSyncEvent.swift @@ -5,14 +5,8 @@ // Created by opfic on 4/29/26. // -import Foundation - -enum WidgetSyncEvent { - case todaySnapshotChanged( - todos: [TodayTodoItem], - displayOptions: TodayDisplayOptions - ) - case heatmapSnapshotChanged( - selectedActivityKinds: Set - ) +enum WidgetSyncEvent: Equatable { + case todoDataChanged + case todayDisplayOptionsChanged + case heatmapActivityKindsChanged } diff --git a/DevLog_Unit/Widget/WidgetSyncEventTests.swift b/DevLog_Unit/Widget/WidgetSyncEventTests.swift index 41e5d373..d0a92e96 100644 --- a/DevLog_Unit/Widget/WidgetSyncEventTests.swift +++ b/DevLog_Unit/Widget/WidgetSyncEventTests.swift @@ -10,58 +10,10 @@ import Testing @testable import DevLog struct WidgetSyncEventTests { - @Test("Today 스냅샷 변경 이벤트는 Todo 목록과 표시 옵션을 담는다") - func today_스냅샷_변경_이벤트는_Todo_목록과_표시_옵션을_담는다() throws { - let todo = try makeTodayTodoItem() - let displayOptions = TodayDisplayOptions( - dueDateVisibility: .withDueDateOnly, - focusVisibility: .focusedOnly - ) - let event = WidgetSyncEvent.todaySnapshotChanged( - todos: [todo], - displayOptions: displayOptions - ) - - guard case .todaySnapshotChanged(let todos, let options) = event else { - Issue.record("Today snapshot event expected") - return - } - #expect(todos == [todo]) - #expect(options == displayOptions) - } - - @Test("Heatmap 스냅샷 변경 이벤트는 선택된 활동 종류를 담는다") - func heatmap_스냅샷_변경_이벤트는_선택된_활동_종류를_담는다() { - let activityKinds: Set = [.created, .completed] - let event = WidgetSyncEvent.heatmapSnapshotChanged( - selectedActivityKinds: activityKinds - ) - - guard case .heatmapSnapshotChanged(let selectedActivityKinds) = event else { - Issue.record("Heatmap snapshot event expected") - return - } - #expect(selectedActivityKinds == activityKinds) - } - - private func makeTodayTodoItem() throws -> TodayTodoItem { - let todo = Todo( - id: "todo-1", - isPinned: true, - isCompleted: false, - isChecked: false, - number: 1, - title: "위젯 동기화", - content: "", - createdAt: .now, - updatedAt: .now, - completedAt: nil, - deletedAt: nil, - dueDate: .now, - tags: [], - category: .system(.feature) - ) - - return try #require(TodayTodoItem(from: todo)) + @Test("위젯 동기화 이벤트는 변경 원인만 표현한다") + func 위젯_동기화_이벤트는_변경_원인만_표현한다() { + #expect(WidgetSyncEvent.todoDataChanged == .todoDataChanged) + #expect(WidgetSyncEvent.todayDisplayOptionsChanged == .todayDisplayOptionsChanged) + #expect(WidgetSyncEvent.heatmapActivityKindsChanged == .heatmapActivityKindsChanged) } } From c71c6d66135c726aa5ce44b26be0b4778b068ef5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 16:53:57 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20=EC=9C=84=EC=A0=AF=EA=B3=BC=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Widget/Sync/WidgetSyncEventBus.swift | 13 ++++++++ .../Widget/Sync/WidgetSyncEventBusImpl.swift | 20 ++++++++++++ .../Widget/WidgetSyncEventBusTests.swift | 31 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 DevLog/Widget/Sync/WidgetSyncEventBus.swift create mode 100644 DevLog/Widget/Sync/WidgetSyncEventBusImpl.swift create mode 100644 DevLog_Unit/Widget/WidgetSyncEventBusTests.swift diff --git a/DevLog/Widget/Sync/WidgetSyncEventBus.swift b/DevLog/Widget/Sync/WidgetSyncEventBus.swift new file mode 100644 index 00000000..b5f069be --- /dev/null +++ b/DevLog/Widget/Sync/WidgetSyncEventBus.swift @@ -0,0 +1,13 @@ +// +// WidgetSyncEventBus.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Combine + +protocol WidgetSyncEventBus { + func publish(_ event: WidgetSyncEvent) + func observe() -> AnyPublisher +} diff --git a/DevLog/Widget/Sync/WidgetSyncEventBusImpl.swift b/DevLog/Widget/Sync/WidgetSyncEventBusImpl.swift new file mode 100644 index 00000000..41a560d2 --- /dev/null +++ b/DevLog/Widget/Sync/WidgetSyncEventBusImpl.swift @@ -0,0 +1,20 @@ +// +// WidgetSyncEventBusImpl.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Combine + +final class WidgetSyncEventBusImpl: WidgetSyncEventBus { + private let subject = PassthroughSubject() + + func publish(_ event: WidgetSyncEvent) { + subject.send(event) + } + + func observe() -> AnyPublisher { + subject.eraseToAnyPublisher() + } +} diff --git a/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift b/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift new file mode 100644 index 00000000..d115eb70 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift @@ -0,0 +1,31 @@ +// +// WidgetSyncEventBusTests.swift +// DevLog_Unit +// +// Created by opfic on 4/30/26. +// + +import Combine +import Testing +@testable import DevLog + +struct WidgetSyncEventBusTests { + @Test("WidgetSyncEventBus는 발행된 이벤트를 관찰자에게 전달한다") + func widgetSyncEventBus는_발행된_이벤트를_관찰자에게_전달한다() { + let bus = WidgetSyncEventBusImpl() + var receivedEvents = [WidgetSyncEvent]() + let cancellable = bus.observe() + .sink { event in + receivedEvents.append(event) + } + + bus.publish(.todoDataChanged) + bus.publish(.todayDisplayOptionsChanged) + + #expect(receivedEvents == [ + .todoDataChanged, + .todayDisplayOptionsChanged + ]) + _ = cancellable + } +} From 374c85441c3ddeaf441f42708717fe4bd434aab7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 17:39:33 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20=EC=9C=84=EC=A0=AF=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Widget/Sync/WidgetSyncEventHandler.swift | 164 +++++++++++++ .../Widget/WidgetSyncEventHandlerTests.swift | 217 ++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 DevLog/Widget/Sync/WidgetSyncEventHandler.swift create mode 100644 DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift diff --git a/DevLog/Widget/Sync/WidgetSyncEventHandler.swift b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift new file mode 100644 index 00000000..e44a2175 --- /dev/null +++ b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift @@ -0,0 +1,164 @@ +// +// WidgetSyncEventHandler.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Combine +import Foundation + +final class WidgetSyncEventHandler { + private let repository: TodoRepository + private let snapshotUpdater: WidgetSnapshotUpdater + private let pageSize = 100 + private let logger = Logger(category: "WidgetSyncEventHandler") + private var cancellables = Set() + + init( + eventBus: WidgetSyncEventBus, + repository: TodoRepository, + snapshotUpdater: WidgetSnapshotUpdater + ) { + self.repository = repository + self.snapshotUpdater = snapshotUpdater + + eventBus.observe() + .sink { [weak self] event in + self?.handle(event) + } + .store(in: &cancellables) + } +} + +private extension WidgetSyncEventHandler { + func handle(_ event: WidgetSyncEvent) { + switch event { + case .todoDataChanged: + Task { [weak self] in + guard let self else { return } + async let todaySnapshot: Void = updateTodayWidgetSnapshot() + async let heatmapSnapshot: Void = updateHeatmapWidgetSnapshot() + _ = await (todaySnapshot, heatmapSnapshot) + } + case .todayDisplayOptionsChanged: + Task { [weak self] in + await self?.updateTodayWidgetSnapshot() + } + case .heatmapActivityKindsChanged: + Task { [weak self] in + await self?.updateHeatmapWidgetSnapshot() + } + } + } + + func updateTodayWidgetSnapshot() async { + do { + async let todosWithDueDate = fetchTodayTodos( + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest + ) + async let todosWithoutDueDate = fetchTodayTodos( + dueDateFilter: .withoutDueDate, + sortTarget: .updatedAt, + sortOrder: .latest + ) + let (todayTodosWithDueDate, todayTodosWithoutDueDate) = try await ( + todosWithDueDate, + todosWithoutDueDate + ) + snapshotUpdater.updateTodaySnapshot( + todos: todayTodosWithDueDate + todayTodosWithoutDueDate + ) + } catch { + logger.error( + "Failed to fetch today widget snapshot data.", + error: error + ) + } + } + + func updateHeatmapWidgetSnapshot() async { + let currentDate = Date() + let quarterStart = snapshotUpdater.startOfQuarter(for: currentDate) + guard let nextQuarterStart = Calendar.current.date(byAdding: .month, value: 3, to: quarterStart) else { + return + } + + do { + async let createdTodos = fetchHeatmapTodos( + sortTarget: .createdAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + async let completedTodos = fetchHeatmapTodos( + sortTarget: .completedAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + async let deletedTodos = fetchHeatmapTodos( + sortTarget: .deletedAt, + quarterStart: quarterStart, + nextQuarterStart: nextQuarterStart + ) + let (createdTodoItems, completedTodoItems, deletedTodoItems) = try await ( + createdTodos, + completedTodos, + deletedTodos + ) + snapshotUpdater.updateHeatmapSnapshot( + createdTodos: createdTodoItems, + completedTodos: completedTodoItems, + deletedTodos: deletedTodoItems, + quarterStart: quarterStart, + now: currentDate + ) + } catch { + logger.error( + "Failed to fetch heatmap widget snapshot data.", + error: error + ) + } + } + + func fetchTodayTodos( + dueDateFilter: TodoQuery.DueDateFilter, + sortTarget: TodoQuery.SortTarget, + sortOrder: TodoQuery.SortOrder + ) async throws -> [TodayTodoItem] { + let todoPage = try await repository.fetchTodos( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: dueDateFilter, + sortTarget: sortTarget, + sortOrder: sortOrder, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + + return todoPage.items.compactMap { TodayTodoItem(from: $0) } + } + + 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 + ) + + return todoPage.items + } +} diff --git a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift new file mode 100644 index 00000000..99758661 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift @@ -0,0 +1,217 @@ +// +// WidgetSyncEventHandlerTests.swift +// DevLog_Unit +// +// Created by opfic on 4/30/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct WidgetSyncEventHandlerTests { + @Test("Todo 데이터 변경 이벤트는 Today와 Heatmap 스냅샷을 갱신한다") + func todo_데이터_변경_이벤트는_today와_heatmap_스냅샷을_갱신한다() async throws { + let calendar = Calendar.current + let now = Date() + let quarterStart = startOfQuarter(for: now, calendar: calendar) + let fixture = makeFixture(calendar: calendar) + + await fixture.todoRepository.setTodos( + todayTodosWithDueDate: [ + makeTodo(id: "today", createdAt: now, dueDate: now) + ], + createdTodos: [ + makeTodo(id: "created", createdAt: now) + ], + completedTodos: [ + makeTodo(id: "completed", createdAt: quarterStart, completedAt: now) + ], + deletedTodos: [ + makeTodo(id: "deleted", createdAt: quarterStart, deletedAt: now) + ] + ) + + fixture.bus.publish(.todoDataChanged) + + let todaySnapshot = try await loadTodaySnapshot(from: fixture.snapshotStore) + let heatmapSnapshot = try await loadHeatmapSnapshot(from: fixture.snapshotStore) + let queries = await fixture.todoRepository.calledQueries() + + #expect(todaySnapshot.totalCount == 1) + #expect(heatmapSnapshot.maxCount == 3) + #expect(queries.count == 5) + #expect(Set(queries.map(\.sortTarget)) == Set([ + .dueDate, + .updatedAt, + .createdAt, + .completedAt, + .deletedAt + ])) + _ = fixture.handler + } + + private func makeFixture( + calendar: Calendar + ) -> ( + bus: WidgetSyncEventBusImpl, + todoRepository: WidgetSyncTodoRepositorySpy, + snapshotStore: WidgetSnapshotStore, + handler: WidgetSyncEventHandler + ) { + let suiteName = "WidgetSyncEventHandlerTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard + userDefaults.removePersistentDomain(forName: suiteName) + let bus = WidgetSyncEventBusImpl() + let todoRepository = WidgetSyncTodoRepositorySpy() + let snapshotStore = WidgetSnapshotStore( + store: WidgetSharedDefaultsStore(userDefaults: userDefaults) + ) + let preferenceStore = WidgetSnapshotPreferenceStore( + userDefaults: userDefaults + ) + let updater = WidgetSnapshotUpdater( + snapshotStore: snapshotStore, + preferenceStore: preferenceStore, + heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar), + calendar: calendar + ) + let handler = WidgetSyncEventHandler( + eventBus: bus, + todoRepository: todoRepository, + widgetSnapshotUpdater: updater + ) + + return (bus, todoRepository, snapshotStore, handler) + } + + private func loadTodaySnapshot( + from snapshotStore: WidgetSnapshotStore + ) async throws -> TodayWidgetSnapshot { + for _ in 0..<20 { + if let snapshot = try snapshotStore.loadTodaySnapshot() { + return snapshot + } + try await Task.sleep(nanoseconds: 50_000_000) + } + + return try #require(try snapshotStore.loadTodaySnapshot()) + } + + private func loadHeatmapSnapshot( + from snapshotStore: WidgetSnapshotStore + ) async throws -> HeatmapWidgetSnapshot { + for _ in 0..<20 { + if let snapshot = try snapshotStore.loadHeatmapSnapshot() { + return snapshot + } + try await Task.sleep(nanoseconds: 50_000_000) + } + + return try #require(try snapshotStore.loadHeatmapSnapshot()) + } + + 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 startOfQuarter( + for date: Date, + calendar: Calendar + ) -> Date { + let month = calendar.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = calendar.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return calendar.date(from: components) ?? calendar.startOfDay(for: date) + } +} + +private actor WidgetSyncTodoRepositorySpy: TodoRepository { + private var queries = [TodoQuery]() + private var todayTodosWithDueDate = [Todo]() + private var todayTodosWithoutDueDate = [Todo]() + private var createdTodos = [Todo]() + private var completedTodos = [Todo]() + private var deletedTodos = [Todo]() + + func setTodos( + todayTodosWithDueDate: [Todo] = [], + todayTodosWithoutDueDate: [Todo] = [], + createdTodos: [Todo] = [], + completedTodos: [Todo] = [], + deletedTodos: [Todo] = [] + ) { + self.todayTodosWithDueDate = todayTodosWithDueDate + self.todayTodosWithoutDueDate = todayTodosWithoutDueDate + self.createdTodos = createdTodos + self.completedTodos = completedTodos + self.deletedTodos = deletedTodos + } + + func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + + let items: [Todo] + switch query.sortTarget { + case .dueDate: + items = todayTodosWithDueDate + case .updatedAt: + items = todayTodosWithoutDueDate + case .createdAt: + items = createdTodos + case .completedAt: + items = completedTodos + case .deletedAt: + items = deletedTodos + } + + return TodoPage(items: items, nextCursor: nil) + } + + func fetchTodo(_ todoId: String) async throws -> Todo { + throw DataError.invalidData("WidgetSyncTodoRepositorySpy.fetchTodo should not be called") + } + + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { + throw DataError.invalidData("WidgetSyncTodoRepositorySpy.fetchReferences should not be called") + } + + func upsertTodo(_ todo: Todo) async throws { + throw DataError.invalidData("WidgetSyncTodoRepositorySpy.upsertTodo should not be called") + } + + func deleteTodo(_ todoId: String) async throws { + throw DataError.invalidData("WidgetSyncTodoRepositorySpy.deleteTodo should not be called") + } + + func undoDeleteTodo(_ todoId: String) async throws { + throw DataError.invalidData("WidgetSyncTodoRepositorySpy.undoDeleteTodo should not be called") + } + + func calledQueries() -> [TodoQuery] { + queries + } +} From 0d5dbdf8dfb22b3d39ef5262a4e8491f5f33eb01 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 17:45:05 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20=ED=95=B8=EB=93=A4=EB=9F=AC,=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B2=84=EC=8A=A4=20DI=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/AppLayerAssembler.swift | 10 ++++++++++ DevLog/App/Assembler/DataAssembler.swift | 2 +- DevLog/App/Delegate/AppDelegate.swift | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/DevLog/App/Assembler/AppLayerAssembler.swift b/DevLog/App/Assembler/AppLayerAssembler.swift index 6809ff43..bf6afad2 100644 --- a/DevLog/App/Assembler/AppLayerAssembler.swift +++ b/DevLog/App/Assembler/AppLayerAssembler.swift @@ -7,6 +7,16 @@ 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(FCMTokenSyncHandler.self) { FCMTokenSyncHandler( userService: container.resolve(UserService.self) diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index 99a2f6c6..af78dcd8 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -30,7 +30,7 @@ final class DataAssembler: Assembler { TodoRepositoryImpl( todoService: container.resolve(TodoService.self), todoCategoryService: container.resolve(TodoCategoryService.self), - widgetSnapshotUpdater: container.resolve(WidgetSnapshotUpdater.self) + widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) ) } diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index b2cdb7fa..b779384f 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -28,6 +28,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { FirebaseApp.configure() _ = container.resolve(FCMTokenSyncHandler.self) _ = container.resolve(UserTimeZoneSyncHandler.self) + _ = container.resolve(WidgetSyncEventHandler.self) // 알림 권한 요청 UNUserNotificationCenter.current().delegate = self From ea436e34538740d54918fd4d46794bd723284be5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 17:45:22 +0900 Subject: [PATCH 12/16] =?UTF-8?q?refactor:=20=EB=A6=AC=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=8B=A8=EC=97=90=EC=84=9C=20=EC=8B=B1?= =?UTF-8?q?=ED=81=AC=20=EC=B1=85=EC=9E=84=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repository/TodoRepositoryImpl.swift | 134 +----------------- 1 file changed, 6 insertions(+), 128 deletions(-) diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index 497f2f05..067f18e6 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -10,19 +10,16 @@ import Foundation final class TodoRepositoryImpl: TodoRepository { private let todoService: TodoService private let todoCategoryService: TodoCategoryService - private let widgetSnapshotUpdater: WidgetSnapshotUpdater - private let calendar = Calendar.current - private let pageSize = 100 - private let logger = Logger(category: "TodoRepositoryImpl") + private let widgetSyncEventBus: WidgetSyncEventBus init( todoService: TodoService, todoCategoryService: TodoCategoryService, - widgetSnapshotUpdater: WidgetSnapshotUpdater + widgetSyncEventBus: WidgetSyncEventBus ) { self.todoService = todoService self.todoCategoryService = todoCategoryService - self.widgetSnapshotUpdater = widgetSnapshotUpdater + self.widgetSyncEventBus = widgetSyncEventBus } func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { @@ -95,140 +92,21 @@ final class TodoRepositoryImpl: TodoRepository { func upsertTodo(_ todo: Todo) async throws { let request = TodoRequest.fromDomain(todo) try await todoService.upsertTodo(request: request) - updateWidgetSnapshots() + widgetSyncEventBus.publish(.todoDataChanged) } func deleteTodo(_ todoId: String) async throws { try await todoService.deleteTodo(todoId: todoId) - updateWidgetSnapshots() + widgetSyncEventBus.publish(.todoDataChanged) } func undoDeleteTodo(_ todoId: String) async throws { try await todoService.undoDeleteTodo(todoId: todoId) - updateWidgetSnapshots() + widgetSyncEventBus.publish(.todoDataChanged) } } private extension TodoRepositoryImpl { - func updateWidgetSnapshots() { - Task { [weak self] in - guard let self else { return } - async let todaySnapshot: Void = updateTodayWidgetSnapshot() - async let heatmapSnapshot: Void = updateHeatmapWidgetSnapshot() - _ = await (todaySnapshot, heatmapSnapshot) - } - } - - func updateTodayWidgetSnapshot() async { - do { - async let todosWithDueDate = fetchTodayTodos( - dueDateFilter: .withDueDate, - sortTarget: .dueDate, - sortOrder: .oldest - ) - async let todosWithoutDueDate = fetchTodayTodos( - dueDateFilter: .withoutDueDate, - sortTarget: .updatedAt, - sortOrder: .latest - ) - let (todayTodosWithDueDate, todayTodosWithoutDueDate) = try await ( - todosWithDueDate, - todosWithoutDueDate - ) - widgetSnapshotUpdater.updateTodaySnapshot( - todos: todayTodosWithDueDate + todayTodosWithoutDueDate - ) - } catch { - logger.error( - "Failed to fetch today widget snapshot data.", - error: error - ) - } - } - - func updateHeatmapWidgetSnapshot() async { - let now = Date() - let quarterStart = widgetSnapshotUpdater.startOfQuarter(for: now) - guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { - return - } - - do { - async let createdTodos = fetchHeatmapTodos( - sortTarget: .createdAt, - quarterStart: quarterStart, - nextQuarterStart: nextQuarterStart - ) - async let completedTodos = fetchHeatmapTodos( - sortTarget: .completedAt, - quarterStart: quarterStart, - nextQuarterStart: nextQuarterStart - ) - async let deletedTodos = fetchHeatmapTodos( - sortTarget: .deletedAt, - quarterStart: quarterStart, - nextQuarterStart: nextQuarterStart - ) - let (createdTodoItems, completedTodoItems, deletedTodoItems) = try await ( - createdTodos, - completedTodos, - deletedTodos - ) - widgetSnapshotUpdater.updateHeatmapSnapshot( - createdTodos: createdTodoItems, - completedTodos: completedTodoItems, - deletedTodos: deletedTodoItems, - quarterStart: quarterStart, - now: now - ) - } catch { - logger.error( - "Failed to fetch heatmap widget snapshot data.", - error: error - ) - } - } - - func fetchTodayTodos( - dueDateFilter: TodoQuery.DueDateFilter, - sortTarget: TodoQuery.SortTarget, - sortOrder: TodoQuery.SortOrder - ) async throws -> [TodayTodoItem] { - let todoPage = try await fetchTodos( - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: dueDateFilter, - sortTarget: sortTarget, - sortOrder: sortOrder, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil - ) - - return todoPage.items.compactMap { TodayTodoItem(from: $0) } - } - - func fetchHeatmapTodos( - sortTarget: TodoQuery.SortTarget, - quarterStart: Date, - nextQuarterStart: Date - ) async throws -> [Todo] { - let todoPage = try await fetchTodos( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: sortTarget, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil - ) - - return todoPage.items - } - func resolve( _ response: TodoResponse, userTodoCategories: [UserTodoCategory] From 8e683ce7b78cc5c5e718208273ab74b9c01192de Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 17:56:59 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor:=20=EC=8B=B1=ED=81=AC=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=EC=9D=84=20Presentation=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=97=90=EC=84=9C=20Data=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DataAssembler.swift | 3 ++- .../UserPreferencesRepositoryImpl.swift | 7 ++++++- .../ViewModel/ProfileViewModel.swift | 17 ++--------------- .../Presentation/ViewModel/TodayViewModel.swift | 13 ------------- 4 files changed, 10 insertions(+), 30 deletions(-) diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index af78dcd8..658da1f6 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -98,7 +98,8 @@ final class DataAssembler: Assembler { UserPreferencesRepositoryImpl( store: container.resolve(UserDefaultsStore.self), themeStore: container.resolve(ThemeStore.self), - widgetSnapshotPreferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self) + widgetSnapshotPreferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self), + widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) ) } } diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift index 4b8c2ade..b1b23b55 100644 --- a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -20,15 +20,18 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { private let store: UserDefaultsStore private let themeStore: ThemeStore private let widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore + private let widgetSyncEventBus: WidgetSyncEventBus init( store: UserDefaultsStore, themeStore: ThemeStore, - widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore + widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore, + widgetSyncEventBus: WidgetSyncEventBus ) { self.store = store self.themeStore = themeStore self.widgetSnapshotPreferenceStore = widgetSnapshotPreferenceStore + self.widgetSyncEventBus = widgetSyncEventBus themeStore.send(systemTheme()) } @@ -90,6 +93,7 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setHeatmapActivityTypes(_ activityTypes: [String]) { widgetSnapshotPreferenceStore.setHeatmapActivityTypes(activityTypes) + widgetSyncEventBus.publish(.heatmapActivityKindsChanged) } func todayDisplayOptions() -> TodayDisplayOptions { @@ -98,5 +102,6 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setTodayDisplayOptions(_ options: TodayDisplayOptions) { widgetSnapshotPreferenceStore.setTodayDisplayOptions(options) + widgetSyncEventBus.publish(.todayDisplayOptionsChanged) } } diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 0c11e9ec..67893ba0 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -61,7 +61,6 @@ final class ProfileViewModel: Store { case fetchActivityQuarter(Date) case updateStatusMessage(String) case updateHeatmapActivityKinds(Set) - case syncHeatmapWidget } private(set) var state = State() @@ -71,10 +70,8 @@ final class ProfileViewModel: Store { private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase private let fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase private let updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase - private let widgetCoordinator: HeatmapWidgetSyncCoordinator private let calendar = Calendar.current private let loadingState = LoadingState() - private var syncHeatmapWidgetTask: Task? private var cancellables = Set() init( @@ -91,9 +88,6 @@ final class ProfileViewModel: Store { self.networkConnectivityUseCase = networkConnectivityUseCase self.fetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase self.updateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase - self.widgetCoordinator = HeatmapWidgetSyncCoordinator( - fetchTodosUseCase: fetchTodosUseCase - ) setupNetworkObserving() } @@ -107,7 +101,7 @@ final class ProfileViewModel: Store { guard let quarterStart = quarterStart(for: Date()) else { break } state.selectedQuarterStart = quarterStart } - effects = [.fetchUserData, .syncHeatmapWidget] + effects = [.fetchUserData] let rawValues = fetchHeatmapActivityTypesUseCase.execute() let settings = normalizeActivityKinds(rawValues) if !settings.isEmpty { @@ -180,7 +174,7 @@ final class ProfileViewModel: Store { } else { state.selectedActivityKinds.insert(activityKind) } - effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds), .syncHeatmapWidget] + effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds)] case .willUpdateStatusMessage: if !state.isNetworkConnected { break } let message = self.state.statusMessage @@ -241,13 +235,6 @@ final class ProfileViewModel: Store { return activityKinds.contains(activityKind) } updateHeatmapActivityTypesUseCase.execute(rawValues) - case .syncHeatmapWidget: - syncHeatmapWidgetTask?.cancel() - syncHeatmapWidgetTask = Task { [selectedActivityKinds = state.selectedActivityKinds] in - await widgetCoordinator.sync( - selectedActivityKinds: selectedActivityKinds - ) - } } } } diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index 515a7ac5..53e1f675 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -71,7 +71,6 @@ final class TodayViewModel: Store { case fetchTodos case completeTodo(TodayTodoItem) case togglePinned(TodayTodoItem) - case syncTodayWidget } private(set) var state = State() @@ -83,7 +82,6 @@ final class TodayViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase private let loadingState = LoadingState() - private let widgetCoordinator = TodayWidgetSyncCoordinator() init( fetchTodosUseCase: FetchTodosUseCase, @@ -258,11 +256,6 @@ final class TodayViewModel: Store { send(.setAlert(true)) } } - case .syncTodayWidget: - widgetCoordinator.sync( - todos: state.todos, - displayOptions: state.displayOptions - ) } } } @@ -292,15 +285,12 @@ private extension TodayViewModel { case .setDueDateVisibility(let visibility): state.displayOptions.dueDateVisibility = visibility updateTodayDisplayOptionsUseCase.execute(state.displayOptions) - return [.syncTodayWidget] case .setFocusVisibility(let visibility): state.displayOptions.focusVisibility = visibility updateTodayDisplayOptionsUseCase.execute(state.displayOptions) - return [.syncTodayWidget] case .resetDisplayOptions: state.displayOptions = .default updateTodayDisplayOptionsUseCase.execute(state.displayOptions) - return [.syncTodayWidget] case .completeTodo(let item): return [.completeTodo(item)] case .togglePinned(let item): @@ -325,7 +315,6 @@ private extension TodayViewModel { switch action { case .fetchTodos(let items): state.todos = items - return [.syncTodayWidget] case .setLoading(let isLoading): state.isLoading = isLoading case .updateTodo(let item): @@ -334,10 +323,8 @@ private extension TodayViewModel { } else { state.todos.append(item) } - return [.syncTodayWidget] case .removeTodo(let todoId): state.todos.removeAll { $0.id == todoId } - return [.syncTodayWidget] default: break } From ed3508b8295f5b74d0ab961ec70bbbdac519c7f3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 18:00:00 +0900 Subject: [PATCH 14/16] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=BD=94=EB=94=94=EB=84=A4=EC=9D=B4=ED=84=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HeatmapWidgetSyncCoordinator.swift | 105 ------------------ .../Today/TodayWidgetSyncCoordinator.swift | 41 ------- 2 files changed, 146 deletions(-) delete mode 100644 DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift delete mode 100644 DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift deleted file mode 100644 index d86ddb50..00000000 --- a/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// HeatmapWidgetSyncCoordinator.swift -// DevLog -// -// Created by opfic on 4/17/26. -// - -import Foundation -import WidgetKit - -final class HeatmapWidgetSyncCoordinator { - private let fetchTodosUseCase: FetchTodosUseCase - private let factory: HeatmapWidgetSnapshotFactory - private let store: WidgetSnapshotStore - private let calendar: Calendar - private let logger = Logger(category: "HeatmapWidgetSyncCoordinator") - - init( - fetchTodosUseCase: FetchTodosUseCase, - factory: HeatmapWidgetSnapshotFactory = .init(), - store: WidgetSnapshotStore = .init(), - calendar: Calendar = .current - ) { - self.fetchTodosUseCase = fetchTodosUseCase - self.factory = factory - self.store = store - self.calendar = calendar - } - - func sync( - selectedActivityKinds: Set, - now: Date = Date() - ) async { - let quarterStart = startOfQuarter(for: now) - guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { - return - } - - do { - async let createdTodoPage = fetchTodosUseCase.execute( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: .createdAt, - pageSize: 100, - fetchAllPages: true - ), - cursor: nil - ) - async let completedTodoPage = fetchTodosUseCase.execute( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: .completedAt, - pageSize: 100, - fetchAllPages: true - ), - cursor: nil - ) - async let deletedTodoPage = fetchTodosUseCase.execute( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: .deletedAt, - pageSize: 100, - fetchAllPages: true - ), - cursor: nil - ) - - let snapshot = factory.makeSnapshot( - createdTodos: try await createdTodoPage.items, - completedTodos: try await completedTodoPage.items, - deletedTodos: try await deletedTodoPage.items, - selectedActivityKinds: selectedActivityKinds, - quarterStart: quarterStart, - now: now - ) - - try store.saveHeatmapSnapshot(snapshot) - WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.heatmap) - } catch is CancellationError { - logger.debug("Heatmap widget sync cancelled.") - } catch { - logger.error( - "Failed to sync heatmap widget snapshot.", - error: error - ) - } - } -} - -private extension HeatmapWidgetSyncCoordinator { - func startOfQuarter(for date: Date) -> Date { - let month = calendar.component(.month, from: date) - let startMonth = ((month - 1) / 3) * 3 + 1 - var components = calendar.dateComponents([.year], from: date) - components.month = startMonth - components.day = 1 - return calendar.date(from: components) ?? calendar.startOfDay(for: date) - } -} diff --git a/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift b/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift deleted file mode 100644 index f070f92e..00000000 --- a/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// TodayWidgetSyncCoordinator.swift -// DevLog -// -// Created by opfic on 4/17/26. -// - -import Foundation -import WidgetKit - -final class TodayWidgetSyncCoordinator { - private let factory: TodayWidgetSnapshotFactory - private let store: WidgetSnapshotStore - - init( - factory: TodayWidgetSnapshotFactory = .init(), - store: WidgetSnapshotStore = .init() - ) { - self.factory = factory - self.store = store - } - - func sync( - todos: [TodayTodoItem], - displayOptions: TodayDisplayOptions, - now: Date = Date() - ) { - let todayWidgetSnapshot = factory.makeSnapshot( - todos: todos, - displayOptions: displayOptions, - now: now - ) - - do { - try store.saveTodaySnapshot(todayWidgetSnapshot) - WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.todayTodo) - } catch { - return - } - } -} From cd26441ebc6781399efbeac4e60ddab999bef391 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 18:20:06 +0900 Subject: [PATCH 15/16] =?UTF-8?q?refactor:=20startOfQuater=EC=9D=84=20Cale?= =?UTF-8?q?ndar=EC=9D=98=20Extension=EC=9C=BC=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Domain/Extension/Calendar.swift | 19 ++++++++++++++++++ .../Persistence/WidgetSnapshotUpdater.swift | 14 +------------ .../HeatmapWidgetSnapshotFactory.swift | 11 +--------- .../Widget/Sync/WidgetSyncEventHandler.swift | 2 +- .../Widget/WidgetSnapshotUpdaterTests.swift | 3 +-- .../Widget/WidgetSyncEventHandlerTests.swift | 20 ++++--------------- 6 files changed, 27 insertions(+), 42 deletions(-) create mode 100644 DevLog/Domain/Extension/Calendar.swift diff --git a/DevLog/Domain/Extension/Calendar.swift b/DevLog/Domain/Extension/Calendar.swift new file mode 100644 index 00000000..2d985212 --- /dev/null +++ b/DevLog/Domain/Extension/Calendar.swift @@ -0,0 +1,19 @@ +// +// Calendar.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Foundation + +extension Calendar { + func startOfQuarter(for date: Date) -> Date { + let month = component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return self.date(from: components) ?? startOfDay(for: date) + } +} diff --git a/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift b/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift index 1418878d..38c54eb7 100644 --- a/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift +++ b/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift @@ -13,21 +13,18 @@ final class WidgetSnapshotUpdater { private let preferenceStore: WidgetSnapshotPreferenceStore private let todayFactory: TodayWidgetSnapshotFactory private let heatmapFactory: HeatmapWidgetSnapshotFactory - private let calendar: Calendar private let logger = Logger(category: "WidgetSnapshotUpdater") init( snapshotStore: WidgetSnapshotStore, preferenceStore: WidgetSnapshotPreferenceStore, todayFactory: TodayWidgetSnapshotFactory = .init(), - heatmapFactory: HeatmapWidgetSnapshotFactory = .init(), - calendar: Calendar = .current + heatmapFactory: HeatmapWidgetSnapshotFactory = .init() ) { self.snapshotStore = snapshotStore self.preferenceStore = preferenceStore self.todayFactory = todayFactory self.heatmapFactory = heatmapFactory - self.calendar = calendar } func updateTodaySnapshot( @@ -107,13 +104,4 @@ final class WidgetSnapshotUpdater { ) } } - - func startOfQuarter(for date: Date) -> Date { - let month = calendar.component(.month, from: date) - let startMonth = ((month - 1) / 3) * 3 + 1 - var components = calendar.dateComponents([.year], from: date) - components.month = startMonth - components.day = 1 - return calendar.date(from: components) ?? calendar.startOfDay(for: date) - } } diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift index 3b62cf95..864ba664 100644 --- a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift @@ -39,7 +39,7 @@ struct HeatmapWidgetSnapshotFactory { quarterStart: Date, now: Date = Date() ) -> HeatmapWidgetSnapshot { - let normalizedQuarterStart = startOfQuarter(for: quarterStart) + let normalizedQuarterStart = calendar.startOfQuarter(for: quarterStart) guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: normalizedQuarterStart) else { return HeatmapWidgetSnapshot( generatedAt: now, @@ -207,15 +207,6 @@ private extension HeatmapWidgetSnapshotFactory { return weeks } - func startOfQuarter(for date: Date) -> Date { - let month = calendar.component(.month, from: date) - let startMonth = ((month - 1) / 3) * 3 + 1 - var components = calendar.dateComponents([.year], from: date) - components.month = startMonth - components.day = 1 - return calendar.date(from: components) ?? calendar.startOfDay(for: date) - } - func maxCount( from months: [WidgetHeatmapMonthSnapshot], selectedActivityKinds: Set diff --git a/DevLog/Widget/Sync/WidgetSyncEventHandler.swift b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift index e44a2175..b3be70e4 100644 --- a/DevLog/Widget/Sync/WidgetSyncEventHandler.swift +++ b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift @@ -81,7 +81,7 @@ private extension WidgetSyncEventHandler { func updateHeatmapWidgetSnapshot() async { let currentDate = Date() - let quarterStart = snapshotUpdater.startOfQuarter(for: currentDate) + let quarterStart = Calendar.current.startOfQuarter(for: currentDate) guard let nextQuarterStart = Calendar.current.date(byAdding: .month, value: 3, to: quarterStart) else { return } diff --git a/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift index e5720536..404b72ae 100644 --- a/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift +++ b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift @@ -81,8 +81,7 @@ struct WidgetSnapshotUpdaterTests { let updater = WidgetSnapshotUpdater( snapshotStore: snapshotStore, preferenceStore: preferenceStore, - heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar), - calendar: calendar + heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar) ) return (updater, snapshotStore) } diff --git a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift index 99758661..709f8d94 100644 --- a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift +++ b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift @@ -14,7 +14,7 @@ struct WidgetSyncEventHandlerTests { func todo_데이터_변경_이벤트는_today와_heatmap_스냅샷을_갱신한다() async throws { let calendar = Calendar.current let now = Date() - let quarterStart = startOfQuarter(for: now, calendar: calendar) + let quarterStart = calendar.startOfQuarter(for: now) let fixture = makeFixture(calendar: calendar) await fixture.todoRepository.setTodos( @@ -73,13 +73,12 @@ struct WidgetSyncEventHandlerTests { let updater = WidgetSnapshotUpdater( snapshotStore: snapshotStore, preferenceStore: preferenceStore, - heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar), - calendar: calendar + heatmapFactory: HeatmapWidgetSnapshotFactory(calendar: calendar) ) let handler = WidgetSyncEventHandler( eventBus: bus, - todoRepository: todoRepository, - widgetSnapshotUpdater: updater + repository: todoRepository, + snapshotUpdater: updater ) return (bus, todoRepository, snapshotStore, handler) @@ -136,17 +135,6 @@ struct WidgetSyncEventHandlerTests { ) } - private func startOfQuarter( - for date: Date, - calendar: Calendar - ) -> Date { - let month = calendar.component(.month, from: date) - let startMonth = ((month - 1) / 3) * 3 + 1 - var components = calendar.dateComponents([.year], from: date) - components.month = startMonth - components.day = 1 - return calendar.date(from: components) ?? calendar.startOfDay(for: date) - } } private actor WidgetSyncTodoRepositorySpy: TodoRepository { From 040876d449e09f34e1d1b2b347704c8425fdd169 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 30 Apr 2026 18:31:44 +0900 Subject: [PATCH 16/16] =?UTF-8?q?refactor:=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=95=B1=EC=9D=B4=20=EB=B0=B1=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=EB=A1=9C=20=EB=82=B4=EB=A0=A4=EA=B0=94?= =?UTF-8?q?=EC=9D=84=EB=95=8C=EB=A1=9C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DataAssembler.swift | 6 ++---- DevLog/App/DevLogApp.swift | 5 +++++ DevLog/Data/Repository/TodoRepositoryImpl.swift | 8 +------- .../Repository/UserPreferencesRepositoryImpl.swift | 7 +------ DevLog/Widget/Common/WidgetSyncEvent.swift | 4 +--- DevLog/Widget/Sync/WidgetSyncEventHandler.swift | 10 +--------- DevLog_Unit/Widget/WidgetSyncEventBusTests.swift | 8 ++------ DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift | 6 +++--- DevLog_Unit/Widget/WidgetSyncEventTests.swift | 8 +++----- 9 files changed, 19 insertions(+), 43 deletions(-) diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index 658da1f6..682b47f4 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -29,8 +29,7 @@ final class DataAssembler: Assembler { container.register(TodoRepository.self) { TodoRepositoryImpl( todoService: container.resolve(TodoService.self), - todoCategoryService: container.resolve(TodoCategoryService.self), - widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) + todoCategoryService: container.resolve(TodoCategoryService.self) ) } @@ -98,8 +97,7 @@ final class DataAssembler: Assembler { UserPreferencesRepositoryImpl( store: container.resolve(UserDefaultsStore.self), themeStore: container.resolve(ThemeStore.self), - widgetSnapshotPreferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self), - widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) + widgetSnapshotPreferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self) ) } } diff --git a/DevLog/App/DevLogApp.swift b/DevLog/App/DevLogApp.swift index dd2034ea..ee994292 100644 --- a/DevLog/App/DevLogApp.swift +++ b/DevLog/App/DevLogApp.swift @@ -11,6 +11,7 @@ import SwiftUI struct DevLogApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate @Environment(\.diContainer) var container: DIContainer + @Environment(\.scenePhase) var scenePhase init() { AppAssembler().assemble(AppDIContainer.shared) @@ -24,6 +25,10 @@ struct DevLogApp: App { systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self) )) .autocorrectionDisabled() + .onChange(of: scenePhase) { _, phase in + guard phase == .background else { return } + container.resolve(WidgetSyncEventBus.self).publish(.syncRequested) + } } } } diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index 067f18e6..ec3a49d3 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -10,16 +10,13 @@ import Foundation final class TodoRepositoryImpl: TodoRepository { private let todoService: TodoService private let todoCategoryService: TodoCategoryService - private let widgetSyncEventBus: WidgetSyncEventBus init( todoService: TodoService, - todoCategoryService: TodoCategoryService, - widgetSyncEventBus: WidgetSyncEventBus + todoCategoryService: TodoCategoryService ) { self.todoService = todoService self.todoCategoryService = todoCategoryService - self.widgetSyncEventBus = widgetSyncEventBus } func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { @@ -92,17 +89,14 @@ final class TodoRepositoryImpl: TodoRepository { func upsertTodo(_ todo: Todo) async throws { let request = TodoRequest.fromDomain(todo) try await todoService.upsertTodo(request: request) - widgetSyncEventBus.publish(.todoDataChanged) } func deleteTodo(_ todoId: String) async throws { try await todoService.deleteTodo(todoId: todoId) - widgetSyncEventBus.publish(.todoDataChanged) } func undoDeleteTodo(_ todoId: String) async throws { try await todoService.undoDeleteTodo(todoId: todoId) - widgetSyncEventBus.publish(.todoDataChanged) } } diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift index b1b23b55..4b8c2ade 100644 --- a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -20,18 +20,15 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { private let store: UserDefaultsStore private let themeStore: ThemeStore private let widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore - private let widgetSyncEventBus: WidgetSyncEventBus init( store: UserDefaultsStore, themeStore: ThemeStore, - widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore, - widgetSyncEventBus: WidgetSyncEventBus + widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore ) { self.store = store self.themeStore = themeStore self.widgetSnapshotPreferenceStore = widgetSnapshotPreferenceStore - self.widgetSyncEventBus = widgetSyncEventBus themeStore.send(systemTheme()) } @@ -93,7 +90,6 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setHeatmapActivityTypes(_ activityTypes: [String]) { widgetSnapshotPreferenceStore.setHeatmapActivityTypes(activityTypes) - widgetSyncEventBus.publish(.heatmapActivityKindsChanged) } func todayDisplayOptions() -> TodayDisplayOptions { @@ -102,6 +98,5 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setTodayDisplayOptions(_ options: TodayDisplayOptions) { widgetSnapshotPreferenceStore.setTodayDisplayOptions(options) - widgetSyncEventBus.publish(.todayDisplayOptionsChanged) } } diff --git a/DevLog/Widget/Common/WidgetSyncEvent.swift b/DevLog/Widget/Common/WidgetSyncEvent.swift index 24539d48..5358322b 100644 --- a/DevLog/Widget/Common/WidgetSyncEvent.swift +++ b/DevLog/Widget/Common/WidgetSyncEvent.swift @@ -6,7 +6,5 @@ // enum WidgetSyncEvent: Equatable { - case todoDataChanged - case todayDisplayOptionsChanged - case heatmapActivityKindsChanged + case syncRequested } diff --git a/DevLog/Widget/Sync/WidgetSyncEventHandler.swift b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift index b3be70e4..69d6616c 100644 --- a/DevLog/Widget/Sync/WidgetSyncEventHandler.swift +++ b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift @@ -34,21 +34,13 @@ final class WidgetSyncEventHandler { private extension WidgetSyncEventHandler { func handle(_ event: WidgetSyncEvent) { switch event { - case .todoDataChanged: + case .syncRequested: Task { [weak self] in guard let self else { return } async let todaySnapshot: Void = updateTodayWidgetSnapshot() async let heatmapSnapshot: Void = updateHeatmapWidgetSnapshot() _ = await (todaySnapshot, heatmapSnapshot) } - case .todayDisplayOptionsChanged: - Task { [weak self] in - await self?.updateTodayWidgetSnapshot() - } - case .heatmapActivityKindsChanged: - Task { [weak self] in - await self?.updateHeatmapWidgetSnapshot() - } } } diff --git a/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift b/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift index d115eb70..f8c55ee6 100644 --- a/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift +++ b/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift @@ -19,13 +19,9 @@ struct WidgetSyncEventBusTests { receivedEvents.append(event) } - bus.publish(.todoDataChanged) - bus.publish(.todayDisplayOptionsChanged) + bus.publish(.syncRequested) - #expect(receivedEvents == [ - .todoDataChanged, - .todayDisplayOptionsChanged - ]) + #expect(receivedEvents == [.syncRequested]) _ = cancellable } } diff --git a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift index 709f8d94..0323b4a2 100644 --- a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift +++ b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift @@ -10,8 +10,8 @@ import Testing @testable import DevLog struct WidgetSyncEventHandlerTests { - @Test("Todo 데이터 변경 이벤트는 Today와 Heatmap 스냅샷을 갱신한다") - func todo_데이터_변경_이벤트는_today와_heatmap_스냅샷을_갱신한다() async throws { + @Test("위젯 동기화 요청 이벤트는 Today와 Heatmap 스냅샷을 갱신한다") + func 위젯_동기화_요청_이벤트는_today와_heatmap_스냅샷을_갱신한다() async throws { let calendar = Calendar.current let now = Date() let quarterStart = calendar.startOfQuarter(for: now) @@ -32,7 +32,7 @@ struct WidgetSyncEventHandlerTests { ] ) - fixture.bus.publish(.todoDataChanged) + fixture.bus.publish(.syncRequested) let todaySnapshot = try await loadTodaySnapshot(from: fixture.snapshotStore) let heatmapSnapshot = try await loadHeatmapSnapshot(from: fixture.snapshotStore) diff --git a/DevLog_Unit/Widget/WidgetSyncEventTests.swift b/DevLog_Unit/Widget/WidgetSyncEventTests.swift index d0a92e96..e3681a9d 100644 --- a/DevLog_Unit/Widget/WidgetSyncEventTests.swift +++ b/DevLog_Unit/Widget/WidgetSyncEventTests.swift @@ -10,10 +10,8 @@ import Testing @testable import DevLog struct WidgetSyncEventTests { - @Test("위젯 동기화 이벤트는 변경 원인만 표현한다") - func 위젯_동기화_이벤트는_변경_원인만_표현한다() { - #expect(WidgetSyncEvent.todoDataChanged == .todoDataChanged) - #expect(WidgetSyncEvent.todayDisplayOptionsChanged == .todayDisplayOptionsChanged) - #expect(WidgetSyncEvent.heatmapActivityKindsChanged == .heatmapActivityKindsChanged) + @Test("위젯 동기화 이벤트는 동기화 요청만 표현한다") + func 위젯_동기화_이벤트는_동기화_요청만_표현한다() { + #expect(WidgetSyncEvent.syncRequested == .syncRequested) } }