diff --git a/DevLog.xcodeproj/project.pbxproj b/DevLog.xcodeproj/project.pbxproj index e94976e3..dcb2743b 100644 --- a/DevLog.xcodeproj/project.pbxproj +++ b/DevLog.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ DFABA3B02E23526500FEFBDB /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = DFABA3AF2E23526500FEFBDB /* FirebaseFirestore */; }; DFABA3B22E23526500FEFBDB /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = DFABA3B12E23526500FEFBDB /* FirebaseFunctions */; }; DFABA3B42E23526500FEFBDB /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = DFABA3B32E23526500FEFBDB /* FirebaseMessaging */; }; + DFD3A9722F8E89DD001DA7CD /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFD3A9712F8E89DD001DA7CD /* WidgetKit.framework */; }; + DFD3A9742F8E89DD001DA7CD /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFD3A9732F8E89DD001DA7CD /* SwiftUI.framework */; }; + DFD3A97F2F8E89DF001DA7CD /* DevLogWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DFD3A9702F8E89DD001DA7CD /* DevLogWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DFD3B68D2F8F1FB8001DA7CD /* Nexa in Frameworks */ = {isa = PBXBuildFile; productRef = DFD3B68C2F8F1FB8001DA7CD /* Nexa */; }; DFD645402EC827A10073E133 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = DFD6453F2EC827A10073E133 /* .gitignore */; }; DFD74E2F2E423EA700613803 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DFD74E2E2E423EA700613803 /* README.md */; }; @@ -29,10 +32,34 @@ remoteGlobalIDString = DFD48AFF2DC4D6E2005905C5; remoteInfo = DevLog; }; + DFD3A97D2F8E89DF001DA7CD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DFD48AF82DC4D6E2005905C5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DFD3A96F2F8E89DD001DA7CD; + remoteInfo = DevLogWidgetExtension; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + DFD3A9802F8E89DF001DA7CD /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DFD3A97F2F8E89DF001DA7CD /* DevLogWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ DF3416492E45F67C00F9312B /* DevLog_Unit.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DevLog_Unit.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DFD3A9702F8E89DD001DA7CD /* DevLogWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DevLogWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DFD3A9712F8E89DD001DA7CD /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + DFD3A9732F8E89DD001DA7CD /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; DFD48B002DC4D6E2005905C5 /* DevLog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DevLog.app; sourceTree = BUILT_PRODUCTS_DIR; }; DFD6453F2EC827A10073E133 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; DFD74E2E2E423EA700613803 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -47,6 +74,13 @@ ); target = DFD48AFF2DC4D6E2005905C5 /* DevLog */; }; + DFD3A9842F8E89DF001DA7CD /* Exceptions for "DevLogWidget" folder in "DevLogWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resource/Info.plist, + ); + target = DFD3A96F2F8E89DD001DA7CD /* DevLogWidgetExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -63,6 +97,19 @@ path = DevLog; sourceTree = ""; }; + DFD3A9752F8E89DD001DA7CD /* DevLogWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DFD3A9842F8E89DF001DA7CD /* Exceptions for "DevLogWidget" folder in "DevLogWidgetExtension" target */, + ); + path = DevLogWidget; + sourceTree = ""; + }; + FB02A8C62F900000001DA7CD /* WidgetShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = WidgetShared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -73,6 +120,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DFD3A96D2F8E89DD001DA7CD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DFD3A9742F8E89DD001DA7CD /* SwiftUI.framework in Frameworks */, + DFD3A9722F8E89DD001DA7CD /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD48AFD2DC4D6E2005905C5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -96,10 +152,12 @@ DFD48AF72DC4D6E2005905C5 = { isa = PBXGroup; children = ( + DFD74E2E2E423EA700613803 /* README.md */, DFD6453F2EC827A10073E133 /* .gitignore */, DF8AB7982E938B0B00E50BBF /* DevLog */, DF34164A2E45F67C00F9312B /* DevLog_Unit */, - DFD74E2E2E423EA700613803 /* README.md */, + DFD3A9752F8E89DD001DA7CD /* DevLogWidget */, + FB02A8C62F900000001DA7CD /* WidgetShared */, DFE28EB62DCCF26300B28FE5 /* Frameworks */, DFD48B012DC4D6E2005905C5 /* Products */, ); @@ -110,6 +168,7 @@ children = ( DF3416492E45F67C00F9312B /* DevLog_Unit.xctest */, DFD48B002DC4D6E2005905C5 /* DevLog.app */, + DFD3A9702F8E89DD001DA7CD /* DevLogWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -117,6 +176,8 @@ DFE28EB62DCCF26300B28FE5 /* Frameworks */ = { isa = PBXGroup; children = ( + DFD3A9712F8E89DD001DA7CD /* WidgetKit.framework */, + DFD3A9732F8E89DD001DA7CD /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -147,6 +208,29 @@ productReference = DF3416492E45F67C00F9312B /* DevLog_Unit.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + DFD3A96F2F8E89DD001DA7CD /* DevLogWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = DFD3A9832F8E89DF001DA7CD /* Build configuration list for PBXNativeTarget "DevLogWidgetExtension" */; + buildPhases = ( + DFD3A96C2F8E89DD001DA7CD /* Sources */, + DFD3A96D2F8E89DD001DA7CD /* Frameworks */, + DFD3A96E2F8E89DD001DA7CD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DFD3A9752F8E89DD001DA7CD /* DevLogWidget */, + FB02A8C62F900000001DA7CD /* WidgetShared */, + ); + name = DevLogWidgetExtension; + packageProductDependencies = ( + ); + productName = DevLogWidgetExtension; + productReference = DFD3A9702F8E89DD001DA7CD /* DevLogWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; DFD48AFF2DC4D6E2005905C5 /* DevLog */ = { isa = PBXNativeTarget; buildConfigurationList = DFD48B112DC4D6E4005905C5 /* Build configuration list for PBXNativeTarget "DevLog" */; @@ -154,14 +238,17 @@ DFD48AFC2DC4D6E2005905C5 /* Sources */, DFD48AFD2DC4D6E2005905C5 /* Frameworks */, DFD48AFE2DC4D6E2005905C5 /* Resources */, + DFD3A9802F8E89DF001DA7CD /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( DF66A07D2EA52E9F0098E643 /* PBXTargetDependency */, + DFD3A97E2F8E89DF001DA7CD /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DF8AB7982E938B0B00E50BBF /* DevLog */, + FB02A8C62F900000001DA7CD /* WidgetShared */, ); name = DevLog; packageProductDependencies = ( @@ -187,13 +274,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 2600; TargetAttributes = { DF3416442E45F67C00F9312B = { CreatedOnToolsVersion = 16.3; TestTargetID = DFD48AFF2DC4D6E2005905C5; }; + DFD3A96F2F8E89DD001DA7CD = { + CreatedOnToolsVersion = 26.4; + }; DFD48AFF2DC4D6E2005905C5 = { CreatedOnToolsVersion = 16.3; }; @@ -223,6 +313,7 @@ targets = ( DF3416442E45F67C00F9312B /* DevLog_Unit */, DFD48AFF2DC4D6E2005905C5 /* DevLog */, + DFD3A96F2F8E89DD001DA7CD /* DevLogWidgetExtension */, ); }; /* End PBXProject section */ @@ -235,6 +326,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DFD3A96E2F8E89DD001DA7CD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD48AFE2DC4D6E2005905C5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -254,6 +352,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DFD3A96C2F8E89DD001DA7CD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD48AFC2DC4D6E2005905C5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -273,6 +378,11 @@ isa = PBXTargetDependency; productRef = DF66A07C2EA52E9F0098E643 /* SwiftLintBuildToolPlugin */; }; + DFD3A97E2F8E89DF001DA7CD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DFD3A96F2F8E89DD001DA7CD /* DevLogWidgetExtension */; + targetProxy = DFD3A97D2F8E89DF001DA7CD /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -285,7 +395,7 @@ DEVELOPMENT_TEAM = 4CPC6N38WA; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog_Unit; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -310,7 +420,7 @@ DEVELOPMENT_TEAM = 4CPC6N38WA; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog_Unit; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -326,6 +436,72 @@ }; name = Release; }; + DFD3A9812F8E89DF001DA7CD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = DevLogWidget/Resource/DevLogWidget.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4CPC6N38WA; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DevLogWidget/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DevLogWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.1; + PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog.DevLogWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DFD3A9822F8E89DF001DA7CD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = DevLogWidget/Resource/DevLogWidget.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4CPC6N38WA; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DevLogWidget/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DevLogWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.1; + PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog.DevLogWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; DFD48B122DC4D6E4005905C5 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = DF8AB7982E938B0B00E50BBF /* DevLog */; @@ -561,6 +737,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DFD3A9832F8E89DF001DA7CD /* Build configuration list for PBXNativeTarget "DevLogWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DFD3A9812F8E89DF001DA7CD /* Debug */, + DFD3A9822F8E89DF001DA7CD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DFD48AFB2DC4D6E2005905C5 /* Build configuration list for PBXProject "DevLog" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/DevLog.xcodeproj/xcshareddata/xcschemes/DevLogWidgetExtension.xcscheme b/DevLog.xcodeproj/xcshareddata/xcschemes/DevLogWidgetExtension.xcscheme new file mode 100644 index 00000000..ce2c141f --- /dev/null +++ b/DevLog.xcodeproj/xcshareddata/xcschemes/DevLogWidgetExtension.xcscheme @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DevLog.xcodeproj/xcuserdata/opfic.xcuserdatad/xcschemes/xcschememanagement.plist b/DevLog.xcodeproj/xcuserdata/opfic.xcuserdatad/xcschemes/xcschememanagement.plist index 8d3cbfd3..d9ba791d 100644 --- a/DevLog.xcodeproj/xcuserdata/opfic.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/DevLog.xcodeproj/xcuserdata/opfic.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 0 + DevLogWidgetExtension.xcscheme_^#shared#^_ + + orderHint + 3 + DevLog_Unit.xcscheme_^#shared#^_ orderHint @@ -27,6 +32,11 @@ primary + DFD3A96F2F8E89DD001DA7CD + + primary + + DFD48AFF2DC4D6E2005905C5 primary diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 16ee4695..197bf429 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -61,6 +61,7 @@ final class ProfileViewModel: Store { case fetchActivityQuarter(Date) case updateStatusMessage(String) case updateHeatmapActivityKinds(Set) + case syncHeatmapWidget } private(set) var state = State() @@ -70,8 +71,10 @@ final class ProfileViewModel: Store { private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase private let fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase + private let widgetCoordinator: HeatmapWidgetSyncCoordinator private let calendar = Calendar.current private let loadingState = LoadingState() + private var syncHeatmapWidgetTask: Task? private var cancellables = Set() init( @@ -88,6 +91,9 @@ final class ProfileViewModel: Store { self.networkConnectivityUseCase = networkConnectivityUseCase self.fetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase self.updateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase + self.widgetCoordinator = HeatmapWidgetSyncCoordinator( + fetchTodosUseCase: fetchTodosUseCase + ) setupNetworkObserving() } @@ -101,7 +107,7 @@ final class ProfileViewModel: Store { guard let quarterStart = quarterStart(for: Date()) else { break } state.selectedQuarterStart = quarterStart } - effects = [.fetchUserData] + effects = [.fetchUserData, .syncHeatmapWidget] let rawValues = fetchHeatmapActivityTypesUseCase.execute() let settings = normalizeActivityKinds(rawValues) if !settings.isEmpty { @@ -174,7 +180,7 @@ final class ProfileViewModel: Store { } else { state.selectedActivityKinds.insert(activityKind) } - effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds)] + effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds), .syncHeatmapWidget] case .willUpdateStatusMessage: if !state.isNetworkConnected { break } let message = self.state.statusMessage @@ -235,6 +241,13 @@ 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 a4e7b113..515a7ac5 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -71,6 +71,7 @@ final class TodayViewModel: Store { case fetchTodos case completeTodo(TodayTodoItem) case togglePinned(TodayTodoItem) + case syncTodayWidget } private(set) var state = State() @@ -82,6 +83,7 @@ final class TodayViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase private let loadingState = LoadingState() + private let widgetCoordinator = TodayWidgetSyncCoordinator() init( fetchTodosUseCase: FetchTodosUseCase, @@ -256,6 +258,11 @@ final class TodayViewModel: Store { send(.setAlert(true)) } } + case .syncTodayWidget: + widgetCoordinator.sync( + todos: state.todos, + displayOptions: state.displayOptions + ) } } } @@ -285,12 +292,15 @@ 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): @@ -315,6 +325,7 @@ 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): @@ -323,8 +334,10 @@ private extension TodayViewModel { } else { state.todos.append(item) } + return [.syncTodayWidget] case .removeTodo(let todoId): state.todos.removeAll { $0.id == todoId } + return [.syncTodayWidget] default: break } @@ -422,4 +435,5 @@ private extension TodayViewModel { let dueDay = calendar.startOfDay(for: dueDate) return startOfToday <= dueDay && dueDay <= windowEnd } + } diff --git a/DevLog/Resource/DevLog.entitlements b/DevLog/Resource/DevLog.entitlements index 36fd0193..7049d86a 100644 --- a/DevLog/Resource/DevLog.entitlements +++ b/DevLog/Resource/DevLog.entitlements @@ -8,5 +8,9 @@ Default + com.apple.security.application-groups + + group.opfic.DevLog + diff --git a/DevLog/Resource/Info.plist b/DevLog/Resource/Info.plist index 8bdf699c..13e3de1b 100644 --- a/DevLog/Resource/Info.plist +++ b/DevLog/Resource/Info.plist @@ -2,6 +2,10 @@ + APPSTORE_URL + $(APPSTORE_URL) + APP_REDIRECT_URL + $(APP_REDIRECT_URL) CFBundleURLTypes @@ -14,18 +18,14 @@ - GIDClientID - $(CLIENT_ID) - PRIVACY_POLICY_URL - $(PRIVACY_POLICY_URL) FirebaseAppDelegateProxyEnabled + GIDClientID + $(CLIENT_ID) GITHUB_CLIENT_ID $(GITHUB_CLIENT_ID) - APPSTORE_URL - $(APPSTORE_URL) - APP_REDIRECT_URL - $(APP_REDIRECT_URL) + PRIVACY_POLICY_URL + $(PRIVACY_POLICY_URL) UIBackgroundModes remote-notification diff --git a/DevLog/Widget/Common/WidgetSharedDefaultsStore.swift b/DevLog/Widget/Common/WidgetSharedDefaultsStore.swift new file mode 100644 index 00000000..eedc63ac --- /dev/null +++ b/DevLog/Widget/Common/WidgetSharedDefaultsStore.swift @@ -0,0 +1,24 @@ +// +// WidgetSharedDefaultsStore.swift +// DevLog +// +// Created by opfic on 4/17/26. +// + +import Foundation + +final class WidgetSharedDefaultsStore { + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = UserDefaults(suiteName: WidgetAppGroup.identifier) ?? .standard) { + self.userDefaults = userDefaults + } + + func data(forKey key: String) -> Data? { + userDefaults.data(forKey: key) + } + + func setData(_ value: Data?, forKey key: String) { + userDefaults.set(value, forKey: key) + } +} diff --git a/DevLog/Widget/Common/WidgetSnapshotStore.swift b/DevLog/Widget/Common/WidgetSnapshotStore.swift new file mode 100644 index 00000000..d419a1db --- /dev/null +++ b/DevLog/Widget/Common/WidgetSnapshotStore.swift @@ -0,0 +1,43 @@ +// +// WidgetSnapshotStore.swift +// DevLog +// +// Created by opfic on 4/17/26. +// + +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() + + init(store: WidgetSharedDefaultsStore = .init()) { + self.store = store + } + + func saveTodaySnapshot(_ snapshot: TodayWidgetSnapshot) throws { + let data = try encoder.encode(snapshot) + store.setData(data, forKey: Key.todaySnapshot) + } + + func loadTodaySnapshot() throws -> TodayWidgetSnapshot? { + guard let data = store.data(forKey: Key.todaySnapshot) 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) + } + + func loadHeatmapSnapshot() throws -> HeatmapWidgetSnapshot? { + guard let data = store.data(forKey: Key.heatmapSnapshot) else { return nil } + return try decoder.decode(HeatmapWidgetSnapshot.self, from: data) + } +} diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshot.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshot.swift new file mode 100644 index 00000000..530b5c56 --- /dev/null +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshot.swift @@ -0,0 +1,29 @@ +// +// HeatmapWidgetSnapshot.swift +// DevLog +// +// Created by opfic on 4/17/26. +// + +import Foundation + +struct HeatmapWidgetSnapshot: Codable, Equatable { + let generatedAt: Date + let monthStart: Date + let selectedActivityKindRawValues: [String] + let maxCount: Int + let weeks: [WidgetHeatmapWeekSnapshot] +} + +struct WidgetHeatmapWeekSnapshot: Codable, Equatable { + let id: Int + let days: [WidgetHeatmapDaySnapshot] +} + +struct WidgetHeatmapDaySnapshot: Codable, Equatable { + let date: Date + let createdCount: Int + let completedCount: Int + let deletedCount: Int + let isVisible: Bool +} diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift new file mode 100644 index 00000000..3172c440 --- /dev/null +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSnapshotFactory.swift @@ -0,0 +1,235 @@ +// +// HeatmapWidgetSnapshotFactory.swift +// DevLog +// +// Created by opfic on 4/17/26. +// + +import Foundation + +struct HeatmapWidgetSnapshotFactory { + fileprivate struct DailyCounts { + var createdCount = 0 + var completedCount = 0 + var deletedCount = 0 + + mutating func increment(_ activityKind: ActivityKind) { + switch activityKind { + case .created: + createdCount += 1 + case .completed: + completedCount += 1 + case .deleted: + deletedCount += 1 + } + } + } + + private let calendar: Calendar + + init(calendar: Calendar = .current) { + self.calendar = calendar + } + + func makeSnapshot( + createdTodos: [Todo], + completedTodos: [Todo], + deletedTodos: [Todo], + selectedActivityKinds: Set, + monthStart: Date, + now: Date = Date() + ) -> HeatmapWidgetSnapshot { + let normalizedMonthStart = startOfMonth(for: monthStart) + let dailyCountsByDate = makeDailyCountsByDate( + createdTodos: createdTodos, + completedTodos: completedTodos, + deletedTodos: deletedTodos, + monthStart: normalizedMonthStart + ) + let weeks = makeWeeks( + monthStart: normalizedMonthStart, + dailyCountsByDate: dailyCountsByDate + ) + + return HeatmapWidgetSnapshot( + generatedAt: now, + monthStart: normalizedMonthStart, + selectedActivityKindRawValues: orderedActivityKinds(from: selectedActivityKinds).map(\.rawValue), + maxCount: maxCount( + from: weeks, + selectedActivityKinds: selectedActivityKinds + ), + weeks: weeks + ) + } +} + +private extension HeatmapWidgetSnapshotFactory { + func makeDailyCountsByDate( + createdTodos: [Todo], + completedTodos: [Todo], + deletedTodos: [Todo], + monthStart: Date + ) -> [Date: DailyCounts] { + var dailyCountsByDate = [Date: DailyCounts]() + + for todo in createdTodos { + appendCount( + activityKind: .created, + occurredAt: todo.createdAt, + monthStart: monthStart, + dailyCountsByDate: &dailyCountsByDate + ) + } + + for todo in completedTodos { + guard let completedAt = todo.completedAt else { continue } + appendCount( + activityKind: .completed, + occurredAt: completedAt, + monthStart: monthStart, + dailyCountsByDate: &dailyCountsByDate + ) + } + + for todo in deletedTodos { + guard let deletedAt = todo.deletedAt else { continue } + appendCount( + activityKind: .deleted, + occurredAt: deletedAt, + monthStart: monthStart, + dailyCountsByDate: &dailyCountsByDate + ) + } + + return dailyCountsByDate + } + + func appendCount( + activityKind: ActivityKind, + occurredAt: Date, + monthStart: Date, + dailyCountsByDate: inout [Date: DailyCounts] + ) { + guard isDateInMonth(occurredAt, monthStart: monthStart) else { return } + + let dayStart = calendar.startOfDay(for: occurredAt) + var dailyCounts = dailyCountsByDate[dayStart] ?? DailyCounts() + dailyCounts.increment(activityKind) + dailyCountsByDate[dayStart] = dailyCounts + } + + func makeWeeks( + monthStart: Date, + dailyCountsByDate: [Date: DailyCounts] + ) -> [WidgetHeatmapWeekSnapshot] { + guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart), + let monthLastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), + let firstWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthInterval.start), + let lastWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthLastDay) else { + return [] + } + + var days = [WidgetHeatmapDaySnapshot]() + var cursor = firstWeekInterval.start + + while cursor < lastWeekInterval.end { + let normalizedDate = calendar.startOfDay(for: cursor) + let isVisible = calendar.isDate( + normalizedDate, + equalTo: monthStart, + toGranularity: .month + ) + let dailyCounts = dailyCountsByDate[normalizedDate] ?? DailyCounts() + + days.append( + WidgetHeatmapDaySnapshot( + date: normalizedDate, + createdCount: isVisible ? dailyCounts.createdCount : 0, + completedCount: isVisible ? dailyCounts.completedCount : 0, + deletedCount: isVisible ? dailyCounts.deletedCount : 0, + isVisible: isVisible + ) + ) + + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = nextDay + } + + var weeks = [WidgetHeatmapWeekSnapshot]() + var index = 0 + + while index < days.count { + let endIndex = min(index + 7, days.count) + weeks.append( + WidgetHeatmapWeekSnapshot( + id: weeks.count, + days: Array(days[index.. Date { + guard let monthInterval = calendar.dateInterval(of: .month, for: date) else { + return calendar.startOfDay(for: date) + } + return monthInterval.start + } + + func isDateInMonth( + _ date: Date, + monthStart: Date + ) -> Bool { + calendar.isDate( + calendar.startOfDay(for: date), + equalTo: monthStart, + toGranularity: .month + ) + } + + func maxCount( + from weeks: [WidgetHeatmapWeekSnapshot], + selectedActivityKinds: Set + ) -> Int { + weeks + .flatMap(\.days) + .filter(\.isVisible) + .map { day in + dayCount( + for: day, + selectedActivityKinds: selectedActivityKinds + ) + } + .max() ?? 0 + } + + func dayCount( + for day: WidgetHeatmapDaySnapshot, + selectedActivityKinds: Set + ) -> Int { + var value = 0 + + if selectedActivityKinds.contains(.created) { + value += day.createdCount + } + + if selectedActivityKinds.contains(.completed) { + value += day.completedCount + } + + if selectedActivityKinds.contains(.deleted) { + value += day.deletedCount + } + + return value + } + + func orderedActivityKinds(from activityKinds: Set) -> [ActivityKind] { + let orderedActivityKinds: [ActivityKind] = [.created, .completed, .deleted] + return orderedActivityKinds.filter { activityKinds.contains($0) } + } +} diff --git a/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift new file mode 100644 index 00000000..0351815e --- /dev/null +++ b/DevLog/Widget/Heatmap/HeatmapWidgetSyncCoordinator.swift @@ -0,0 +1,104 @@ +// +// 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 monthStart = startOfMonth(for: now) + guard let nextMonthStart = calendar.date(byAdding: .month, value: 1, to: monthStart) else { + return + } + + do { + async let createdTodoPage = fetchTodosUseCase.execute( + TodoQuery( + sortDateFrom: monthStart, + sortDateTo: nextMonthStart, + includesDeleted: true, + sortTarget: .createdAt, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + async let completedTodoPage = fetchTodosUseCase.execute( + TodoQuery( + sortDateFrom: monthStart, + sortDateTo: nextMonthStart, + includesDeleted: true, + sortTarget: .completedAt, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + async let deletedTodoPage = fetchTodosUseCase.execute( + TodoQuery( + sortDateFrom: monthStart, + sortDateTo: nextMonthStart, + 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, + monthStart: monthStart, + 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 startOfMonth(for date: Date) -> Date { + guard let monthInterval = calendar.dateInterval(of: .month, for: date) else { + return calendar.startOfDay(for: date) + } + + return monthInterval.start + } +} diff --git a/DevLog/Widget/Today/TodayWidgetSnapshot.swift b/DevLog/Widget/Today/TodayWidgetSnapshot.swift new file mode 100644 index 00000000..65f3504b --- /dev/null +++ b/DevLog/Widget/Today/TodayWidgetSnapshot.swift @@ -0,0 +1,30 @@ +// +// TodayWidgetSnapshot.swift +// DevLog +// +// Created by opfic on 4/17/26. +// + +import Foundation + +struct TodayWidgetSnapshot: Codable, Equatable { + let generatedAt: Date + let totalCount: Int + let focusedCount: Int + let overdueCount: Int + let dueSoonCount: Int + let sections: [TodayWidgetSectionSnapshot] +} + +struct TodayWidgetSectionSnapshot: Codable, Equatable { + let category: String + let items: [WidgetTodoSnapshotItem] +} + +struct WidgetTodoSnapshotItem: Codable, Equatable { + let id: String + let number: Int + let title: String + let isPinned: Bool + let dueDate: Date? +} diff --git a/DevLog/Widget/Today/TodayWidgetSnapshotFactory.swift b/DevLog/Widget/Today/TodayWidgetSnapshotFactory.swift new file mode 100644 index 00000000..f6e72f78 --- /dev/null +++ b/DevLog/Widget/Today/TodayWidgetSnapshotFactory.swift @@ -0,0 +1,182 @@ +// +// TodayWidgetSnapshotFactory.swift +// DevLog +// +// Created by opfic on 4/17/26. +// + +import Foundation + +struct TodayWidgetSnapshotFactory { + private enum SectionCategory: String, CaseIterable { + case focused + case overdue + case dueSoon + case later + case unscheduled + } + + private struct SectionCollection { + var focused = [TodayTodoItem]() + var overdue = [TodayTodoItem]() + var dueSoon = [TodayTodoItem]() + var later = [TodayTodoItem]() + var unscheduled = [TodayTodoItem]() + + func items(for category: SectionCategory) -> [TodayTodoItem] { + switch category { + case .focused: + focused + case .overdue: + overdue + case .dueSoon: + dueSoon + case .later: + later + case .unscheduled: + unscheduled + } + } + } + + private let calendar: Calendar + private let upcomingWindowDays: Int + + init( + calendar: Calendar = .current, + upcomingWindowDays: Int = 7 + ) { + self.calendar = calendar + self.upcomingWindowDays = upcomingWindowDays + } + + func makeSnapshot( + todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions, + now: Date = Date() + ) -> TodayWidgetSnapshot { + let displayedTodos = displayedTodos( + from: todos, + displayOptions: displayOptions + ) + let sections = groupedSectionItems( + from: displayedTodos, + now: now + ) + + return TodayWidgetSnapshot( + generatedAt: now, + totalCount: displayedTodos.count, + focusedCount: displayedTodos.filter(\.isPinned).count, + overdueCount: displayedTodos.filter { isOverdue($0, now: now) }.count, + dueSoonCount: displayedTodos.filter { isDueSoon($0, now: now) }.count, + sections: SectionCategory.allCases.compactMap { category in + let items = sections.items(for: category) + guard !items.isEmpty else { return nil } + + return TodayWidgetSectionSnapshot( + category: category.rawValue, + items: items.map { + WidgetTodoSnapshotItem( + id: $0.id, + number: $0.number, + title: $0.title, + isPinned: $0.isPinned, + dueDate: $0.dueDate + ) + } + ) + } + ) + } + + private func displayedTodos( + from todos: [TodayTodoItem], + displayOptions: TodayDisplayOptions + ) -> [TodayTodoItem] { + let dueDateFilteredTodos: [TodayTodoItem] + switch displayOptions.dueDateVisibility { + case .all: + dueDateFilteredTodos = todos + case .withDueDateOnly: + dueDateFilteredTodos = todos.filter { $0.dueDate != nil } + case .withoutDueDateOnly: + dueDateFilteredTodos = todos.filter { $0.dueDate == nil } + } + + switch displayOptions.focusVisibility { + case .all: + return dueDateFilteredTodos + case .focusedOnly: + return dueDateFilteredTodos.filter(\.isPinned) + } + } + + private func groupedSectionItems( + from items: [TodayTodoItem], + now: Date + ) -> SectionCollection { + let startOfToday = calendar.startOfDay(for: now) + guard let windowEnd = calendar.date( + byAdding: .day, + value: upcomingWindowDays, + to: startOfToday + ) else { + return SectionCollection( + focused: items.filter(\.isPinned), + unscheduled: items.filter { !$0.isPinned && $0.dueDate == nil } + ) + } + + var collection = SectionCollection() + + for item in items { + if item.isPinned { + collection.focused.append(item) + continue + } + + guard let dueDate = item.dueDate else { + collection.unscheduled.append(item) + continue + } + + let dueDay = calendar.startOfDay(for: dueDate) + if dueDay < startOfToday { + collection.overdue.append(item) + } else if dueDay <= windowEnd { + collection.dueSoon.append(item) + } else { + collection.later.append(item) + } + } + + return collection + } + + private func isOverdue( + _ item: TodayTodoItem, + now: Date + ) -> Bool { + guard let dueDate = item.dueDate else { return false } + return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: now) + } + + private func isDueSoon( + _ item: TodayTodoItem, + now: Date + ) -> Bool { + guard let dueDate = item.dueDate else { return false } + let startOfToday = calendar.startOfDay(for: now) + guard let windowEnd = calendar.date( + byAdding: .day, + value: upcomingWindowDays, + to: startOfToday + ) else { + return false + } + + let dueDay = calendar.startOfDay(for: dueDate) + return startOfToday <= dueDay && dueDay <= windowEnd + } +} diff --git a/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift b/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift new file mode 100644 index 00000000..41904d58 --- /dev/null +++ b/DevLog/Widget/Today/TodayWidgetSyncCoordinator.swift @@ -0,0 +1,41 @@ +// +// 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/WidgetPlaceholderCard.swift b/DevLogWidget/Common/WidgetPlaceholderCard.swift new file mode 100644 index 00000000..18ffa723 --- /dev/null +++ b/DevLogWidget/Common/WidgetPlaceholderCard.swift @@ -0,0 +1,28 @@ +// +// WidgetPlaceholderCard.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import SwiftUI + +struct WidgetPlaceholderCard: View { + let title: String + let message: String + + var body: some View { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.12)) + .overlay { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + .overlay(alignment: .topLeading) { + Text(title) + .font(.headline) + .padding(12) + } + } +} diff --git a/DevLogWidget/Common/WidgetSharedDefaultsStore.swift b/DevLogWidget/Common/WidgetSharedDefaultsStore.swift new file mode 100644 index 00000000..1ee41a04 --- /dev/null +++ b/DevLogWidget/Common/WidgetSharedDefaultsStore.swift @@ -0,0 +1,20 @@ +// +// WidgetSharedDefaultsStore.swift +// DevLogWidget +// +// Created by opfic on 4/17/26. +// + +import Foundation + +final class WidgetSharedDefaultsStore { + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = UserDefaults(suiteName: WidgetAppGroup.identifier) ?? .standard) { + self.userDefaults = userDefaults + } + + func data(forKey key: String) -> Data? { + userDefaults.data(forKey: key) + } +} diff --git a/DevLogWidget/Common/WidgetSnapshotStore.swift b/DevLogWidget/Common/WidgetSnapshotStore.swift new file mode 100644 index 00000000..c2aed0d9 --- /dev/null +++ b/DevLogWidget/Common/WidgetSnapshotStore.swift @@ -0,0 +1,32 @@ +// +// WidgetSnapshotStore.swift +// DevLogWidget +// +// Created by opfic on 4/17/26. +// + +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() + + init(store: WidgetSharedDefaultsStore = .init()) { + self.store = store + } + + func loadTodaySnapshot() throws -> TodayWidgetSnapshot? { + guard let data = store.data(forKey: Key.todaySnapshot) 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 } + return try decoder.decode(HeatmapWidgetSnapshot.self, from: data) + } +} diff --git a/DevLogWidget/DevLogWidgetBundle.swift b/DevLogWidget/DevLogWidgetBundle.swift new file mode 100644 index 00000000..9af82ac2 --- /dev/null +++ b/DevLogWidget/DevLogWidgetBundle.swift @@ -0,0 +1,17 @@ +// +// DevLogWidgetBundle.swift +// DevLogWidget +// +// Created by 최윤진 on 4/14/26. +// + +import WidgetKit +import SwiftUI + +@main +struct DevLogWidgetBundle: WidgetBundle { + var body: some Widget { + TodayTodoWidget() + HeatmapWidget() + } +} diff --git a/DevLogWidget/Heatmap/HeatmapWidget.swift b/DevLogWidget/Heatmap/HeatmapWidget.swift new file mode 100644 index 00000000..9da8f917 --- /dev/null +++ b/DevLogWidget/Heatmap/HeatmapWidget.swift @@ -0,0 +1,28 @@ +// +// HeatmapWidget.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import SwiftUI +import AppIntents +import WidgetKit + +struct HeatmapWidget: Widget { + let kind = "HeatmapWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: HeatmapWidgetConfigurationIntent.self, + provider: HeatmapWidgetProvider() + ) { entry in + HeatmapWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Heatmap") + .description("이번 달 활동 히트맵을 표시합니다.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/DevLogWidget/Heatmap/HeatmapWidgetConfigurationIntent.swift b/DevLogWidget/Heatmap/HeatmapWidgetConfigurationIntent.swift new file mode 100644 index 00000000..d6f3761f --- /dev/null +++ b/DevLogWidget/Heatmap/HeatmapWidgetConfigurationIntent.swift @@ -0,0 +1,14 @@ +// +// HeatmapWidgetConfigurationIntent.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import AppIntents +import WidgetKit + +struct HeatmapWidgetConfigurationIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Heatmap" + static var description = IntentDescription("이번 달 활동 히트맵을 표시합니다.") +} diff --git a/DevLogWidget/Heatmap/HeatmapWidgetEntry.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntry.swift new file mode 100644 index 00000000..035f62f7 --- /dev/null +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntry.swift @@ -0,0 +1,13 @@ +// +// HeatmapWidgetEntry.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import WidgetKit + +struct HeatmapWidgetEntry: TimelineEntry { + let date: Date + let snapshot: HeatmapWidgetSnapshot? +} diff --git a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift new file mode 100644 index 00000000..502230c6 --- /dev/null +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift @@ -0,0 +1,65 @@ +// +// HeatmapWidgetEntryView.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import SwiftUI +import WidgetKit + +struct HeatmapWidgetEntryView: View { + let entry: HeatmapWidgetEntry + @Environment(\.widgetFamily) private var widgetFamily + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("이번 달 히트맵") + .font(.headline) + + Spacer() + + if let snapshot = entry.snapshot { + content(snapshot) + } else { + emptyState + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private func content(_ snapshot: HeatmapWidgetSnapshot) -> some View { + if widgetFamily == .systemSmall { + VStack(alignment: .leading, spacing: 4) { + Text("\(snapshot.maxCount)") + .font(.title) + .bold() + Text("이번 달 최대 활동 수") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + WidgetPlaceholderCard( + title: "이번 달 히트맵", + message: "저장된 주차 \(snapshot.weeks.count)개" + ) + .frame(maxWidth: .infinity) + } + } + + @ViewBuilder + private var emptyState: some View { + if widgetFamily == .systemSmall { + Text("앱을 열어\n히트맵을 준비하세요") + .font(.caption) + .foregroundStyle(.secondary) + } else { + WidgetPlaceholderCard( + title: "이번 달 히트맵", + message: "데이터 연결 전" + ) + .frame(maxWidth: .infinity) + } + } +} diff --git a/DevLogWidget/Heatmap/HeatmapWidgetProvider.swift b/DevLogWidget/Heatmap/HeatmapWidgetProvider.swift new file mode 100644 index 00000000..5c4ab2b2 --- /dev/null +++ b/DevLogWidget/Heatmap/HeatmapWidgetProvider.swift @@ -0,0 +1,52 @@ +// +// HeatmapWidgetProvider.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import AppIntents +import WidgetKit + +struct HeatmapWidgetProvider: AppIntentTimelineProvider { + typealias Intent = HeatmapWidgetConfigurationIntent + typealias Entry = HeatmapWidgetEntry + private let store = WidgetSnapshotStore() + + // 위젯 갤러리나 로딩 전 상태에서 즉시 표시할 기본 엔트리. + func placeholder(in context: Context) -> HeatmapWidgetEntry { + .init(date: .now, snapshot: nil) + } + + // 현재 시점의 단일 스냅샷을 만들어 미리보기와 일시적인 렌더링에 사용한다. + func snapshot( + for configuration: HeatmapWidgetConfigurationIntent, + in context: Context + ) async -> HeatmapWidgetEntry { + let snapshot = try? store.loadHeatmapSnapshot() + return .init( + date: snapshot?.generatedAt ?? .now, + snapshot: snapshot + ) + } + + // 실제 위젯이 사용할 타임라인 엔트리를 구성한다. + // 현재 단계에서는 저장된 스냅샷 하나만 내려주고, 갱신은 앱이 별도로 트리거한다. + func timeline( + for configuration: HeatmapWidgetConfigurationIntent, + in context: Context + ) async -> Timeline { + let snapshot = try? store.loadHeatmapSnapshot() + let entries: [HeatmapWidgetEntry] = [ + .init( + date: snapshot?.generatedAt ?? .now, + snapshot: snapshot + ) + ] + + return Timeline( + entries: entries, + policy: .never + ) + } +} diff --git a/DevLogWidget/Heatmap/HeatmapWidgetSnapshot.swift b/DevLogWidget/Heatmap/HeatmapWidgetSnapshot.swift new file mode 100644 index 00000000..19429451 --- /dev/null +++ b/DevLogWidget/Heatmap/HeatmapWidgetSnapshot.swift @@ -0,0 +1,29 @@ +// +// HeatmapWidgetSnapshot.swift +// DevLogWidget +// +// Created by opfic on 4/17/26. +// + +import Foundation + +struct HeatmapWidgetSnapshot: Decodable, Equatable { + let generatedAt: Date + let monthStart: Date + let selectedActivityKindRawValues: [String] + let maxCount: Int + let weeks: [WidgetHeatmapWeekSnapshot] +} + +struct WidgetHeatmapWeekSnapshot: Decodable, Equatable { + let id: Int + let days: [WidgetHeatmapDaySnapshot] +} + +struct WidgetHeatmapDaySnapshot: Decodable, Equatable { + let date: Date + let createdCount: Int + let completedCount: Int + let deletedCount: Int + let isVisible: Bool +} diff --git a/DevLogWidget/Resource/Assets.xcassets/AccentColor.colorset/Contents.json b/DevLogWidget/Resource/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/DevLogWidget/Resource/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DevLogWidget/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json b/DevLogWidget/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/DevLogWidget/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DevLogWidget/Resource/Assets.xcassets/Contents.json b/DevLogWidget/Resource/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/DevLogWidget/Resource/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DevLogWidget/Resource/Assets.xcassets/WidgetBackground.colorset/Contents.json b/DevLogWidget/Resource/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/DevLogWidget/Resource/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DevLogWidget/Resource/DevLogWidget.entitlements b/DevLogWidget/Resource/DevLogWidget.entitlements new file mode 100644 index 00000000..2cc6f897 --- /dev/null +++ b/DevLogWidget/Resource/DevLogWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.opfic.DevLog + + + diff --git a/DevLogWidget/Resource/Info.plist b/DevLogWidget/Resource/Info.plist new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/DevLogWidget/Resource/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/DevLogWidget/Today/TodayTodoWidget.swift b/DevLogWidget/Today/TodayTodoWidget.swift new file mode 100644 index 00000000..4de1f71e --- /dev/null +++ b/DevLogWidget/Today/TodayTodoWidget.swift @@ -0,0 +1,28 @@ +// +// TodayTodoWidget.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import SwiftUI +import AppIntents +import WidgetKit + +struct TodayTodoWidget: Widget { + let kind = "TodayTodoWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: TodayTodoWidgetConfigurationIntent.self, + provider: TodayTodoWidgetProvider() + ) { entry in + TodayTodoWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Today Todo") + .description("오늘 기준 Todo 목록을 표시합니다.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift b/DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift new file mode 100644 index 00000000..3320cfba --- /dev/null +++ b/DevLogWidget/Today/TodayTodoWidgetConfigurationIntent.swift @@ -0,0 +1,14 @@ +// +// TodayTodoWidgetConfigurationIntent.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import AppIntents +import WidgetKit + +struct TodayTodoWidgetConfigurationIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Today Todo" + static var description = IntentDescription("오늘 기준 Todo 목록을 표시합니다.") +} diff --git a/DevLogWidget/Today/TodayTodoWidgetEntry.swift b/DevLogWidget/Today/TodayTodoWidgetEntry.swift new file mode 100644 index 00000000..177571db --- /dev/null +++ b/DevLogWidget/Today/TodayTodoWidgetEntry.swift @@ -0,0 +1,13 @@ +// +// TodayTodoWidgetEntry.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import WidgetKit + +struct TodayTodoWidgetEntry: TimelineEntry { + let date: Date + let snapshot: TodayWidgetSnapshot? +} diff --git a/DevLogWidget/Today/TodayTodoWidgetEntryView.swift b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift new file mode 100644 index 00000000..386cae5e --- /dev/null +++ b/DevLogWidget/Today/TodayTodoWidgetEntryView.swift @@ -0,0 +1,78 @@ +// +// TodayTodoWidgetEntryView.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import SwiftUI +import WidgetKit + +struct TodayTodoWidgetEntryView: View { + let entry: TodayTodoWidgetEntry + @Environment(\.widgetFamily) private var widgetFamily + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Today Todo") + .font(.headline) + + Spacer() + + if let snapshot = entry.snapshot { + content(snapshot) + } else { + emptyState + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private func content(_ snapshot: TodayWidgetSnapshot) -> some View { + switch widgetFamily { + case .systemSmall: + VStack(alignment: .leading, spacing: 4) { + Text("\(snapshot.totalCount)") + .font(.system(size: 28, weight: .bold)) + Text(topItemTitle(from: snapshot)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + case .systemMedium, .systemLarge: + WidgetPlaceholderCard( + title: "Today Todo", + message: "저장된 할 일 \(snapshot.totalCount)개" + ) + .frame(maxWidth: .infinity) + default: + EmptyView() + } + } + + @ViewBuilder + private var emptyState: some View { + switch widgetFamily { + case .systemSmall: + Text("앱을 열어\nToday 위젯을 준비하세요") + .font(.caption) + .foregroundStyle(.secondary) + case .systemMedium, .systemLarge: + WidgetPlaceholderCard( + title: "Today Todo", + message: "데이터 연결 전" + ) + .frame(maxWidth: .infinity) + default: + EmptyView() + } + } + + private func topItemTitle(from snapshot: TodayWidgetSnapshot) -> String { + snapshot.sections + .flatMap(\.items) + .first? + .title ?? "할 일이 없습니다" + } +} diff --git a/DevLogWidget/Today/TodayTodoWidgetProvider.swift b/DevLogWidget/Today/TodayTodoWidgetProvider.swift new file mode 100644 index 00000000..cc9325f0 --- /dev/null +++ b/DevLogWidget/Today/TodayTodoWidgetProvider.swift @@ -0,0 +1,52 @@ +// +// TodayTodoWidgetProvider.swift +// DevLogWidget +// +// Created by opfic on 4/15/26. +// + +import AppIntents +import WidgetKit + +struct TodayTodoWidgetProvider: AppIntentTimelineProvider { + typealias Intent = TodayTodoWidgetConfigurationIntent + typealias Entry = TodayTodoWidgetEntry + private let store = WidgetSnapshotStore() + + // 위젯 갤러리나 로딩 전 상태에서 즉시 표시할 기본 엔트리. + func placeholder(in context: Context) -> TodayTodoWidgetEntry { + .init(date: .now, snapshot: nil) + } + + // 현재 시점의 단일 스냅샷을 만들어 미리보기와 일시적인 렌더링에 사용한다. + func snapshot( + for configuration: TodayTodoWidgetConfigurationIntent, + in context: Context + ) async -> TodayTodoWidgetEntry { + let snapshot = try? store.loadTodaySnapshot() + return .init( + date: snapshot?.generatedAt ?? .now, + snapshot: snapshot + ) + } + + // 실제 위젯이 사용할 타임라인 엔트리를 구성한다. + // 현재 단계에서는 저장된 스냅샷 하나만 내려주고, 갱신은 앱이 별도로 트리거한다. + func timeline( + for configuration: TodayTodoWidgetConfigurationIntent, + in context: Context + ) async -> Timeline { + let snapshot = try? store.loadTodaySnapshot() + let entries: [TodayTodoWidgetEntry] = [ + .init( + date: snapshot?.generatedAt ?? .now, + snapshot: snapshot + ) + ] + + return Timeline( + entries: entries, + policy: .never + ) + } +} diff --git a/DevLogWidget/Today/TodayWidgetSnapshot.swift b/DevLogWidget/Today/TodayWidgetSnapshot.swift new file mode 100644 index 00000000..cf1eb55b --- /dev/null +++ b/DevLogWidget/Today/TodayWidgetSnapshot.swift @@ -0,0 +1,30 @@ +// +// TodayWidgetSnapshot.swift +// DevLogWidget +// +// Created by opfic on 4/17/26. +// + +import Foundation + +struct TodayWidgetSnapshot: Decodable, Equatable { + let generatedAt: Date + let totalCount: Int + let focusedCount: Int + let overdueCount: Int + let dueSoonCount: Int + let sections: [TodayWidgetSectionSnapshot] +} + +struct TodayWidgetSectionSnapshot: Decodable, Equatable { + let category: String + let items: [WidgetTodoSnapshotItem] +} + +struct WidgetTodoSnapshotItem: Decodable, Equatable { + let id: String + let number: Int + let title: String + let isPinned: Bool + let dueDate: Date? +} diff --git a/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift new file mode 100644 index 00000000..544c0298 --- /dev/null +++ b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift @@ -0,0 +1,156 @@ +// +// HeatmapWidgetSnapshotFactoryTests.swift +// DevLog_Unit +// +// Created by opfic on 4/17/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct HeatmapWidgetSnapshotFactoryTests { + @Test("Heatmap 위젯 스냅샷은 이번 달 기준 주차와 일별 count를 만든다") + func heatmap_위젯_스냅샷은_이번_달_기준_주차와_일별_count를_만든다() throws { + let calendar = Calendar(identifier: .gregorian) + let monthStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let aprilThirdDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))! + let mayFirstDate = calendar.date(from: DateComponents(year: 2026, month: 5, day: 1))! + let aprilFifteenthDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 15))! + let factory = HeatmapWidgetSnapshotFactory(calendar: calendar) + + let snapshot = factory.makeSnapshot( + createdTodos: [ + makeTodo( + id: "todo-created-apr-03", + createdAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))) + ), + makeTodo( + id: "todo-created-mar-31", + createdAt: try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 31))) + ) + ], + completedTodos: [ + makeTodo( + id: "todo-completed-apr-03", + createdAt: monthStart, + completedAt: aprilThirdDate + ), + makeTodo( + id: "todo-completed-may-01", + createdAt: monthStart, + completedAt: mayFirstDate + ) + ], + deletedTodos: [ + makeTodo( + id: "todo-deleted-apr-15", + createdAt: monthStart, + deletedAt: aprilFifteenthDate + ) + ], + selectedActivityKinds: [.created, .completed], + monthStart: monthStart, + now: monthStart + ) + + #expect(snapshot.monthStart == monthStart) + #expect(snapshot.selectedActivityKindRawValues == ["created", "completed"]) + #expect(snapshot.maxCount == 2) + #expect(snapshot.weeks.count == 5) + #expect(snapshot.weeks.flatMap(\.days).filter(\.isVisible).count == 30) + + let aprilThird = try #require(day(for: DateComponents(year: 2026, month: 4, day: 3), in: snapshot, calendar: calendar)) + #expect(aprilThird.createdCount == 1) + #expect(aprilThird.completedCount == 1) + #expect(aprilThird.deletedCount == 0) + #expect(aprilThird.isVisible) + + let aprilFifteenth = try #require(day(for: DateComponents(year: 2026, month: 4, day: 15), in: snapshot, calendar: calendar)) + #expect(aprilFifteenth.createdCount == 0) + #expect(aprilFifteenth.completedCount == 0) + #expect(aprilFifteenth.deletedCount == 1) + } + + @Test("Heatmap 위젯 스냅샷 maxCount는 선택된 activity kind만 기준으로 계산한다") + func heatmap_위젯_스냅샷_maxCount는_선택된_activity_kind만_기준으로_계산한다() throws { + let calendar = Calendar(identifier: .gregorian) + let monthStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let targetDate = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 10))) + let factory = HeatmapWidgetSnapshotFactory(calendar: calendar) + + let snapshot = factory.makeSnapshot( + createdTodos: [ + makeTodo(id: "created-1", createdAt: targetDate), + makeTodo(id: "created-2", createdAt: targetDate) + ], + completedTodos: [], + deletedTodos: [ + makeTodo( + id: "deleted-1", + createdAt: monthStart, + deletedAt: targetDate + ), + makeTodo( + id: "deleted-2", + createdAt: monthStart, + deletedAt: targetDate + ), + makeTodo( + id: "deleted-3", + createdAt: monthStart, + deletedAt: targetDate + ) + ], + selectedActivityKinds: [.deleted], + monthStart: monthStart, + now: monthStart + ) + + #expect(snapshot.selectedActivityKindRawValues == ["deleted"]) + #expect(snapshot.maxCount == 3) + + let targetDay = try #require(day(for: DateComponents(year: 2026, month: 4, day: 10), in: snapshot, calendar: calendar)) + #expect(targetDay.createdCount == 2) + #expect(targetDay.deletedCount == 3) + } + + private func day( + for components: DateComponents, + in snapshot: HeatmapWidgetSnapshot, + calendar: Calendar + ) -> WidgetHeatmapDaySnapshot? { + guard let date = calendar.date(from: components) else { return nil } + let targetDate = calendar.startOfDay(for: date) + + return snapshot.weeks + .flatMap(\.days) + .first { day in + calendar.isDate(day.date, inSameDayAs: targetDate) + } + } + + private func makeTodo( + id: String, + createdAt: Date, + completedAt: Date? = nil, + deletedAt: 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: nil, + tags: [], + category: .system(.feature) + ) + } +} diff --git a/DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift b/DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift new file mode 100644 index 00000000..c936114b --- /dev/null +++ b/DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift @@ -0,0 +1,133 @@ +// +// TodayWidgetSnapshotFactoryTests.swift +// DevLog_Unit +// +// Created by opfic on 4/17/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct TodayWidgetSnapshotFactoryTests { + @Test("Today 위젯 스냅샷은 화면 규칙과 같은 순서로 섹션과 요약 수치를 만든다") + func today_위젯_스냅샷은_화면_규칙과_같은_순서로_섹션과_요약_수치를_만든다() throws { + let calendar = Calendar(identifier: .gregorian) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 17))) + let factory = TodayWidgetSnapshotFactory(calendar: calendar) + + let snapshot = factory.makeSnapshot( + todos: try makeTodayTodos(now: now, calendar: calendar), + displayOptions: .default, + now: now + ) + + #expect(snapshot.totalCount == 5) + #expect(snapshot.focusedCount == 1) + #expect(snapshot.overdueCount == 1) + #expect(snapshot.dueSoonCount == 2) + #expect(snapshot.sections.map(\.category) == ["focused", "overdue", "dueSoon", "later", "unscheduled"]) + #expect(snapshot.sections[0].items.map(\.title) == ["고정된 할 일"]) + #expect(snapshot.sections[1].items.map(\.title) == ["지난 일정"]) + #expect(snapshot.sections[2].items.map(\.title) == ["임박 일정"]) + #expect(snapshot.sections[3].items.map(\.title) == ["나중 일정"]) + #expect(snapshot.sections[4].items.map(\.title) == ["미정 일정"]) + } + + @Test("Today 위젯 스냅샷은 화면과 같은 display option 필터를 적용한다") + func today_위젯_스냅샷은_화면과_같은_display_option_필터를_적용한다() throws { + let calendar = Calendar(identifier: .gregorian) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 17))) + let factory = TodayWidgetSnapshotFactory(calendar: calendar) + + let snapshot = factory.makeSnapshot( + todos: try makeTodayTodos(now: now, calendar: calendar), + displayOptions: TodayDisplayOptions( + dueDateVisibility: .withDueDateOnly, + focusVisibility: .focusedOnly + ), + now: now + ) + + #expect(snapshot.totalCount == 1) + #expect(snapshot.focusedCount == 1) + #expect(snapshot.overdueCount == 0) + #expect(snapshot.dueSoonCount == 1) + #expect(snapshot.sections.map(\.category) == ["focused"]) + #expect(snapshot.sections[0].items.map(\.title) == ["고정된 할 일"]) + } + + private func makeTodayTodos( + now: Date, + calendar: Calendar + ) throws -> [TodayTodoItem] { + let overdueDate = try #require(calendar.date(byAdding: .day, value: -1, to: now)) + let dueSoonDate = try #require(calendar.date(byAdding: .day, value: 3, to: now)) + let laterDate = try #require(calendar.date(byAdding: .day, value: 9, to: now)) + + return try [ + makeTodayTodoItem( + id: "todo-1", + number: 1, + title: "고정된 할 일", + isPinned: true, + dueDate: dueSoonDate + ), + makeTodayTodoItem( + id: "todo-2", + number: 2, + title: "지난 일정", + isPinned: false, + dueDate: overdueDate + ), + makeTodayTodoItem( + id: "todo-3", + number: 3, + title: "임박 일정", + isPinned: false, + dueDate: dueSoonDate + ), + makeTodayTodoItem( + id: "todo-4", + number: 4, + title: "나중 일정", + isPinned: false, + dueDate: laterDate + ), + makeTodayTodoItem( + id: "todo-5", + number: 5, + title: "미정 일정", + isPinned: false, + dueDate: nil + ) + ] + } + + private func makeTodayTodoItem( + id: String, + number: Int, + title: String, + isPinned: Bool, + dueDate: Date? + ) throws -> TodayTodoItem { + let todo = Todo( + id: id, + isPinned: isPinned, + isCompleted: false, + isChecked: false, + number: number, + title: title, + content: "", + createdAt: .now, + updatedAt: .now, + completedAt: nil, + deletedAt: nil, + dueDate: dueDate, + tags: [], + category: .system(.feature) + ) + + return try #require(TodayTodoItem(from: todo)) + } +} diff --git a/WidgetShared/WidgetAppGroup.swift b/WidgetShared/WidgetAppGroup.swift new file mode 100644 index 00000000..6625334c --- /dev/null +++ b/WidgetShared/WidgetAppGroup.swift @@ -0,0 +1,12 @@ +// +// WidgetAppGroup.swift +// DevLog +// +// Created by opfic on 4/15/26. +// + +import Foundation + +enum WidgetAppGroup { + static let identifier = "group.opfic.DevLog" +}