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 aaba3440..682b47f4 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -96,7 +96,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 70bd6ced..2eea5242 100644 --- a/DevLog/App/Assembler/PersistenceAssembler.swift +++ b/DevLog/App/Assembler/PersistenceAssembler.swift @@ -18,5 +18,26 @@ 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(WidgetSnapshotPreferenceStore.self) { + WidgetSnapshotPreferenceStore() + } + + container.register(WidgetSnapshotUpdater.self) { + WidgetSnapshotUpdater( + snapshotStore: container.resolve(WidgetSnapshotStore.self), + preferenceStore: container.resolve(WidgetSnapshotPreferenceStore.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 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/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/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/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 } 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 new file mode 100644 index 00000000..38c54eb7 --- /dev/null +++ b/DevLog/Storage/Persistence/WidgetSnapshotUpdater.swift @@ -0,0 +1,107 @@ +// +// WidgetSnapshotUpdater.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Foundation +import WidgetKit + +final class WidgetSnapshotUpdater { + private let snapshotStore: WidgetSnapshotStore + private let preferenceStore: WidgetSnapshotPreferenceStore + private let todayFactory: TodayWidgetSnapshotFactory + private let heatmapFactory: HeatmapWidgetSnapshotFactory + private let logger = Logger(category: "WidgetSnapshotUpdater") + + init( + snapshotStore: WidgetSnapshotStore, + preferenceStore: WidgetSnapshotPreferenceStore, + todayFactory: TodayWidgetSnapshotFactory = .init(), + heatmapFactory: HeatmapWidgetSnapshotFactory = .init() + ) { + self.snapshotStore = snapshotStore + self.preferenceStore = preferenceStore + self.todayFactory = todayFactory + self.heatmapFactory = heatmapFactory + } + + func updateTodaySnapshot( + todos: [TodayTodoItem], + now: Date = Date() + ) { + updateTodaySnapshot( + todos: todos, + displayOptions: preferenceStore.todayDisplayOptions(), + now: now + ) + } + + 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], + 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], + 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 + ) + } + } +} 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/Common/WidgetSyncEvent.swift b/DevLog/Widget/Common/WidgetSyncEvent.swift new file mode 100644 index 00000000..5358322b --- /dev/null +++ b/DevLog/Widget/Common/WidgetSyncEvent.swift @@ -0,0 +1,10 @@ +// +// WidgetSyncEvent.swift +// DevLog +// +// Created by opfic on 4/29/26. +// + +enum WidgetSyncEvent: Equatable { + case syncRequested +} 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/Heatmap/HeatmapWidgetSyncCoordinator.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift deleted file mode 100644 index 019065cb..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: "HeatmapWidget") - } 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/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/Widget/Sync/WidgetSyncEventHandler.swift b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift new file mode 100644 index 00000000..69d6616c --- /dev/null +++ b/DevLog/Widget/Sync/WidgetSyncEventHandler.swift @@ -0,0 +1,156 @@ +// +// 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 .syncRequested: + 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 + ) + 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 = Calendar.current.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/Widget/Today/TodayWidgetSyncCoordinator.swift b/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift deleted file mode 100644 index 41904d58..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: "TodayTodoWidget") - } 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/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) } } 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/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift new file mode 100644 index 00000000..404b72ae --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSnapshotUpdaterTests.swift @@ -0,0 +1,123 @@ +// +// 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 preferenceStore = WidgetSnapshotPreferenceStore( + userDefaults: userDefaults + ) + let updater = WidgetSnapshotUpdater( + snapshotStore: snapshotStore, + preferenceStore: preferenceStore, + heatmapFactory: HeatmapWidgetSnapshotFactory(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) + ) + } +} diff --git a/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift b/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift new file mode 100644 index 00000000..f8c55ee6 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSyncEventBusTests.swift @@ -0,0 +1,27 @@ +// +// 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(.syncRequested) + + #expect(receivedEvents == [.syncRequested]) + _ = cancellable + } +} diff --git a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift new file mode 100644 index 00000000..0323b4a2 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift @@ -0,0 +1,205 @@ +// +// WidgetSyncEventHandlerTests.swift +// DevLog_Unit +// +// Created by opfic on 4/30/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct WidgetSyncEventHandlerTests { + @Test("위젯 동기화 요청 이벤트는 Today와 Heatmap 스냅샷을 갱신한다") + func 위젯_동기화_요청_이벤트는_today와_heatmap_스냅샷을_갱신한다() async throws { + let calendar = Calendar.current + let now = Date() + let quarterStart = calendar.startOfQuarter(for: now) + 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(.syncRequested) + + 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) + ) + let handler = WidgetSyncEventHandler( + eventBus: bus, + repository: todoRepository, + snapshotUpdater: 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 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 + } +} diff --git a/DevLog_Unit/Widget/WidgetSyncEventTests.swift b/DevLog_Unit/Widget/WidgetSyncEventTests.swift new file mode 100644 index 00000000..e3681a9d --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSyncEventTests.swift @@ -0,0 +1,17 @@ +// +// WidgetSyncEventTests.swift +// DevLog_Unit +// +// Created by opfic on 4/29/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct WidgetSyncEventTests { + @Test("위젯 동기화 이벤트는 동기화 요청만 표현한다") + func 위젯_동기화_이벤트는_동기화_요청만_표현한다() { + #expect(WidgetSyncEvent.syncRequested == .syncRequested) + } +} 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" +}