diff --git a/BeeKit/Managers/CurrentUserManager.swift b/BeeKit/Managers/CurrentUserManager.swift index a6bb5803..46d68775 100644 --- a/BeeKit/Managers/CurrentUserManager.swift +++ b/BeeKit/Managers/CurrentUserManager.swift @@ -13,6 +13,8 @@ import KeychainSwift import OSLog import SwiftyJSON +import WidgetKit + @NSModelActor(disableGenerateInit: true) public actor CurrentUserManager { let logger = Logger(subsystem: "com.beeminder.beeminder", category: "CurrentUserManager") @@ -168,12 +170,14 @@ public actor CurrentUserManager { await Task { @MainActor in NotificationCenter.default.post(name: CurrentUserManager.NotificationName.signedIn, object: self) + WidgetCenter.shared.reloadAllTimelines() }.value } func handleFailedSignin(_ responseError: Error, errorMessage : String?) async throws { await Task { @MainActor in NotificationCenter.default.post(name: CurrentUserManager.NotificationName.failedSignIn, object: self, userInfo: ["error" : responseError]) + WidgetCenter.shared.reloadAllTimelines() }.value try await self.signOut() } @@ -190,6 +194,8 @@ public actor CurrentUserManager { await Task { @MainActor in NotificationCenter.default.post(name: CurrentUserManager.NotificationName.signedOut, object: self) + + WidgetCenter.shared.reloadAllTimelines() }.value } } diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index dd3fd116..bc6687b2 100644 --- a/BeeKit/Managers/GoalManager.swift +++ b/BeeKit/Managers/GoalManager.swift @@ -12,7 +12,7 @@ import CoreDataEvolution import SwiftyJSON import OSLog import OrderedCollections - +import WidgetKit @NSModelActor(disableGenerateInit: true) public actor GoalManager { @@ -26,7 +26,11 @@ public actor GoalManager { private let requestManager: RequestManager private nonisolated let currentUserManager: CurrentUserManager - public var goalsFetchedAt : Date? = nil + public var goalsFetchedAt : Date? = nil { + didSet { + WidgetCenter.shared.reloadAllTimelines() + } + } private var queuedGoalsBackgroundTaskRunning : Bool = false @@ -135,6 +139,7 @@ public actor GoalManager { await Task { @MainActor in modelContainer.viewContext.refreshAllObjects() NotificationCenter.default.post(name: GoalManager.NotificationName.goalsUpdated, object: self) + WidgetCenter.shared.reloadAllTimelines() }.value } diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 553cd071..64f9c849 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -8,8 +8,19 @@ /* Begin PBXBuildFile section */ 9B65F2322CFA6427009674A7 /* DeeplinkGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */; }; + 9B037D502CE80290008C25BD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9B037D452CE80290008C25BD /* Assets.xcassets */; }; + 9B037D532CE80290008C25BD /* BeeminderGoalCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B037D472CE80290008C25BD /* BeeminderGoalCountdownWidget.swift */; }; + 9B037D542CE80290008C25BD /* BeeminderGoalListWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B037D482CE80290008C25BD /* BeeminderGoalListWidget.swift */; }; + 9B037D552CE80290008C25BD /* BeeminderPledgedTodayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B037D492CE80290008C25BD /* BeeminderPledgedTodayWidget.swift */; }; + 9B037D572CE80290008C25BD /* BeeminderWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B037D4B2CE80290008C25BD /* BeeminderWidgetBundle.swift */; }; + 9B037D582CE80290008C25BD /* BeeminderWidgetConfigurationIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B037D4C2CE80290008C25BD /* BeeminderWidgetConfigurationIntents.swift */; }; + 9B5860FF2CE4963F00079A1C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9B5860FE2CE4963E00079A1C /* WidgetKit.framework */; }; + 9B5861012CE4963F00079A1C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9B5861002CE4963F00079A1C /* SwiftUI.framework */; }; 9B8CA57D24B120CA009C86C2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */; }; 9BFB27E92CFE770F0056D10D /* FreshnessIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */; }; + 9B93301C2CE55C7F00720B19 /* BeeminderWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9B5860FD2CE4963E00079A1C /* BeeminderWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9B93301F2CE55D0400720B19 /* BeeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E57BE6E02655EBD900BA540B /* BeeKit.framework */; }; + 9B9330202CE55D0400720B19 /* BeeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E57BE6E02655EBD900BA540B /* BeeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A10D4E931B07948500A72D29 /* DatapointsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10D4E921B07948500A72D29 /* DatapointsTableView.swift */; }; A10DC2DF207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10DC2DE207BFCBA00FB7B3A /* RemoveHKMetricViewController.swift */; }; A11A87C61FEBFF7200A43E47 /* ChooseGoalSortViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A87C51FEBFF7200A43E47 /* ChooseGoalSortViewController.swift */; }; @@ -139,6 +150,20 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 9B93301D2CE55C7F00720B19 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A196CB0C1AE4142E00B90A3E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9B5860FC2CE4963E00079A1C; + remoteInfo = BeeminderWidgetExtension; + }; + 9B9330212CE55D0400720B19 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A196CB0C1AE4142E00B90A3E /* Project object */; + proxyType = 1; + remoteGlobalIDString = E57BE6DF2655EBD900BA540B; + remoteInfo = BeeKit; + }; A196CB2D1AE4142F00B90A3E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A196CB0C1AE4142E00B90A3E /* Project object */; @@ -191,12 +216,24 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 9B9330232CE55D0400720B19 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 9B9330202CE55D0400720B19 /* BeeKit.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; A1B53C401B2D04EB00AF266F /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 12; dstPath = ""; dstSubfolderSpec = 13; files = ( + 9B93301C2CE55C7F00720B19 /* BeeminderWidgetExtension.appex in Embed Foundation Extensions */, E5F7C4AA260FC5300095684F /* BeeSwiftIntents.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -217,6 +254,17 @@ /* Begin PBXFileReference section */ 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkGenerator.swift; sourceTree = ""; }; + 9B037D452CE80290008C25BD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 9B037D472CE80290008C25BD /* BeeminderGoalCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeminderGoalCountdownWidget.swift; sourceTree = ""; }; + 9B037D482CE80290008C25BD /* BeeminderGoalListWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeminderGoalListWidget.swift; sourceTree = ""; }; + 9B037D492CE80290008C25BD /* BeeminderPledgedTodayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeminderPledgedTodayWidget.swift; sourceTree = ""; }; + 9B037D4B2CE80290008C25BD /* BeeminderWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeminderWidgetBundle.swift; sourceTree = ""; }; + 9B037D4C2CE80290008C25BD /* BeeminderWidgetConfigurationIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeminderWidgetConfigurationIntents.swift; sourceTree = ""; }; + 9B037D4D2CE80290008C25BD /* BeeminderWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BeeminderWidgetExtension.entitlements; sourceTree = ""; }; + 9B037D4E2CE80290008C25BD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9B5860FD2CE4963E00079A1C /* BeeminderWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BeeminderWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 9B5860FE2CE4963E00079A1C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 9B5861002CE4963F00079A1C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreshnessIndicatorView.swift; sourceTree = ""; }; A10D4E921B07948500A72D29 /* DatapointsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatapointsTableView.swift; sourceTree = ""; }; @@ -331,6 +379,33 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 9B5860FA2CE4963E00079A1C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9B5861012CE4963F00079A1C /* SwiftUI.framework in Frameworks */, + 9B93301F2CE55D0400720B19 /* BeeKit.framework in Frameworks */, + 9B5860FF2CE4963F00079A1C /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A12E694A1BD3EF0200AB94C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E41286F52A62E9840093D598 /* KeychainSwift in Frameworks */, + E462BA4A29ADAAB800E80EF0 /* SnapKit in Frameworks */, + A142492E1FD0A56A007736B3 /* HealthKit.framework in Frameworks */, + E462BA5129ADAB4F00E80EF0 /* MBProgressHUD in Frameworks */, + E462BA5529ADACB600E80EF0 /* Alamofire in Frameworks */, + E462BA4F29ADAB4300E80EF0 /* SwiftyJSON in Frameworks */, + E458C7FC2AD10D6E000DCA5C /* BeeKit.framework in Frameworks */, + A12E694E1BD3EF0200AB94C2 /* NotificationCenter.framework in Frameworks */, + E486DE2829B040FB00F338B2 /* OrderedCollections in Frameworks */, + E462BA5729AEEF9D00E80EF0 /* AlamofireImage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A196CB111AE4142E00B90A3E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -407,6 +482,21 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9B037D4F2CE80290008C25BD /* BeeminderWidget */ = { + isa = PBXGroup; + children = ( + 9B037D452CE80290008C25BD /* Assets.xcassets */, + 9B037D472CE80290008C25BD /* BeeminderGoalCountdownWidget.swift */, + 9B037D482CE80290008C25BD /* BeeminderGoalListWidget.swift */, + 9B037D492CE80290008C25BD /* BeeminderPledgedTodayWidget.swift */, + 9B037D4B2CE80290008C25BD /* BeeminderWidgetBundle.swift */, + 9B037D4C2CE80290008C25BD /* BeeminderWidgetConfigurationIntents.swift */, + 9B037D4D2CE80290008C25BD /* BeeminderWidgetExtension.entitlements */, + 9B037D4E2CE80290008C25BD /* Info.plist */, + ); + path = BeeminderWidget; + sourceTree = ""; + }; A106AD8B1AF1F62800C434E8 /* Managers */ = { isa = PBXGroup; children = ( @@ -452,6 +542,7 @@ E5F7C493260FC5300095684F /* BeeSwiftIntents */, E57BE6E12655EBDA00BA540B /* BeeKit */, E57BE6EE2655EBE000BA540B /* BeeKitTests */, + 9B037D4F2CE80290008C25BD /* BeeminderWidget */, A196CB151AE4142E00B90A3E /* Products */, C039E8389CFF5C0D8CF85D8D /* Frameworks */, ); @@ -466,6 +557,7 @@ E5F7C490260FC5300095684F /* BeeSwiftIntents.appex */, E57BE6E02655EBD900BA540B /* BeeKit.framework */, E57BE6E82655EBDF00BA540B /* BeeKitTests.xctest */, + 9B5860FD2CE4963E00079A1C /* BeeminderWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -550,6 +642,8 @@ A1B53C301B2D04EA00AF266F /* NotificationCenter.framework */, E5F7C491260FC5300095684F /* Intents.framework */, E5F7C49C260FC5300095684F /* IntentsUI.framework */, + 9B5860FE2CE4963E00079A1C /* WidgetKit.framework */, + 9B5861002CE4963F00079A1C /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -708,7 +802,28 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ - A196CB131AE4142E00B90A3E /* BeeSwift */ = { + 9B5860FC2CE4963E00079A1C /* BeeminderWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9B5861162CE4964400079A1C /* Build configuration list for PBXNativeTarget "BeeminderWidgetExtension" */; + buildPhases = ( + 9B5860F92CE4963E00079A1C /* Sources */, + 9B5860FA2CE4963E00079A1C /* Frameworks */, + 9B5860FB2CE4963E00079A1C /* Resources */, + 9B9330232CE55D0400720B19 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 9B9330222CE55D0400720B19 /* PBXTargetDependency */, + ); + name = BeeminderWidgetExtension; + packageProductDependencies = ( + ); + productName = MyFirstWidgetExtension; + productReference = 9B5860FD2CE4963E00079A1C /* BeeminderWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + A196CB131AE4142E00B90A3E /* BeeSwift */ = { isa = PBXNativeTarget; buildConfigurationList = A196CB361AE4142F00B90A3E /* Build configuration list for PBXNativeTarget "BeeSwift" */; buildPhases = ( @@ -723,6 +838,7 @@ dependencies = ( E5F7C4A9260FC5300095684F /* PBXTargetDependency */, E57BE7132655F00200BA540B /* PBXTargetDependency */, + 9B93301E2CE55C7F00720B19 /* PBXTargetDependency */, ); name = BeeSwift; packageProductDependencies = ( @@ -856,10 +972,27 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftMigration = 0710; - LastSwiftUpdateCheck = 1240; + LastSwiftUpdateCheck = 1610; LastUpgradeCheck = 1500; ORGANIZATIONNAME = APB; TargetAttributes = { + 9B5860FC2CE4963E00079A1C = { + CreatedOnToolsVersion = 16.1; + }; + A12E694C1BD3EF0200AB94C2 = { + CreatedOnToolsVersion = 7.1; + DevelopmentTeam = 8TW9V9HVES; + LastSwiftMigration = 1240; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.SafariKeychain = { + enabled = 0; + }; + }; + }; A196CB131AE4142E00B90A3E = { CreatedOnToolsVersion = 6.3; DevelopmentTeam = 8TW9V9HVES; @@ -946,11 +1079,30 @@ E5F7C48F260FC5300095684F /* BeeSwiftIntents */, E57BE6DF2655EBD900BA540B /* BeeKit */, E57BE6E72655EBDF00BA540B /* BeeKitTests */, + 9B5860FC2CE4963E00079A1C /* BeeminderWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 9B5860FB2CE4963E00079A1C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9B037D502CE80290008C25BD /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A12E694B1BD3EF0200AB94C2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E5DF493924DC69A200260560 /* Config.swift.sample in Resources */, + A12E69541BD3EF0200AB94C2 /* MainInterface.storyboard in Resources */, + A1D853311EB3E0A300FC75DE /* Images.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A196CB121AE4142E00B90A3E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1002,6 +1154,18 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 9B5860F92CE4963E00079A1C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9B037D532CE80290008C25BD /* BeeminderGoalCountdownWidget.swift in Sources */, + 9B037D542CE80290008C25BD /* BeeminderGoalListWidget.swift in Sources */, + 9B037D552CE80290008C25BD /* BeeminderPledgedTodayWidget.swift in Sources */, + 9B037D572CE80290008C25BD /* BeeminderWidgetBundle.swift in Sources */, + 9B037D582CE80290008C25BD /* BeeminderWidgetConfigurationIntents.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A196CB101AE4142E00B90A3E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1136,6 +1300,16 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 9B93301E2CE55C7F00720B19 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9B5860FC2CE4963E00079A1C /* BeeminderWidgetExtension */; + targetProxy = 9B93301D2CE55C7F00720B19 /* PBXContainerItemProxy */; + }; + 9B9330222CE55D0400720B19 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E57BE6DF2655EBD900BA540B /* BeeKit */; + targetProxy = 9B9330212CE55D0400720B19 /* PBXContainerItemProxy */; + }; A196CB2E1AE4142F00B90A3E /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A196CB131AE4142E00B90A3E /* BeeSwift */; @@ -1174,6 +1348,89 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 9B5861132CE4964400079A1C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = BeeminderWidget/BeeminderWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 50; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 8TW9V9HVES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BeeminderWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BeeminderWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Beeminder. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.beeminder.beeminder.BeeminderWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9B5861142CE4964400079A1C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = BeeminderWidget/BeeminderWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 50; + DEVELOPMENT_TEAM = 8TW9V9HVES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BeeminderWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BeeminderWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Beeminder. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.beeminder.beeminder.BeeminderWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; A196CB341AE4142F00B90A3E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1705,6 +1962,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 9B5861162CE4964400079A1C /* Build configuration list for PBXNativeTarget "BeeminderWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9B5861132CE4964400079A1C /* Debug */, + 9B5861142CE4964400079A1C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A196CB0F1AE4142E00B90A3E /* Build configuration list for PBXProject "BeeSwift" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeminderWidgetExtension.xcscheme b/BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeminderWidgetExtension.xcscheme new file mode 100644 index 00000000..09794a20 --- /dev/null +++ b/BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeminderWidgetExtension.xcscheme @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BeeSwift/AppDelegate.swift b/BeeSwift/AppDelegate.swift index 5fb1a95f..5ff6581d 100644 --- a/BeeSwift/AppDelegate.swift +++ b/BeeSwift/AppDelegate.swift @@ -14,6 +14,8 @@ import UIKit import IQKeyboardManagerSwift import AlamofireNetworkActivityIndicator +import WidgetKit + import BeeKit @UIApplicationMain @@ -65,12 +67,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD logger.notice("applicationDidEnterBackground") // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + + WidgetCenter.shared.reloadAllTimelines() } func applicationWillEnterForeground(_ application: UIApplication) { logger.notice("applicationWillEnterForeground") // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + WidgetCenter.shared.reloadAllTimelines() } func applicationDidBecomeActive(_ application: UIApplication) { @@ -155,6 +161,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } do { try await ServiceLocator.goalManager.refreshGoals() + + WidgetCenter.shared.reloadAllTimelines() } catch { logger.error("Error refreshing goals: \(error)") } diff --git a/BeeSwift/BackgroundUpdates.swift b/BeeSwift/BackgroundUpdates.swift old mode 100644 new mode 100755 diff --git a/BeeSwift/Gallery/GalleryViewController.swift b/BeeSwift/Gallery/GalleryViewController.swift index 8427eabe..083e011c 100644 --- a/BeeSwift/Gallery/GalleryViewController.swift +++ b/BeeSwift/Gallery/GalleryViewController.swift @@ -14,6 +14,7 @@ import HealthKit import SafariServices import OSLog import CoreData +import WidgetKit import BeeKit @@ -444,15 +445,14 @@ class GalleryViewController: UIViewController, UICollectionViewDelegateFlowLayou } @objc func openGoalFromNotification(_ notification: Notification) { - guard let notif = notification as NSNotification? else { return } var matchingGoal: Goal? - if let identifier = notif.userInfo?["identifier"] as? String { + if let identifier = notification.userInfo?["identifier"] as? String { if let url = URL(string: identifier), let objectID = viewContext.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) { matchingGoal = viewContext.object(with: objectID) as? Goal } } - else if let slug = notif.userInfo?["slug"] as? String { + else if let slug = notification.userInfo?["slug"] as? String { matchingGoal = self.filteredGoals.filter({ (goal) -> Bool in return goal.slug == slug }).last diff --git a/BeeSwift/Info.plist b/BeeSwift/Info.plist index de4c93db..18102555 100644 --- a/BeeSwift/Info.plist +++ b/BeeSwift/Info.plist @@ -100,13 +100,6 @@ NSExceptionDomains - apb.local - - NSExceptionAllowsInsecureHTTPLoads - - NSExceptionRequiresForwardSecrecy - - imac.local NSExceptionAllowsInsecureHTTPLoads diff --git a/BeeminderWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/BeeminderWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/BeeminderWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BeeminderWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/BeeminderWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/BeeminderWidget/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/BeeminderWidget/Assets.xcassets/Contents.json b/BeeminderWidget/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/BeeminderWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BeeminderWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/BeeminderWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/BeeminderWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BeeminderWidget/BeeminderGoalCountdownWidget.swift b/BeeminderWidget/BeeminderGoalCountdownWidget.swift new file mode 100644 index 00000000..46c015bd --- /dev/null +++ b/BeeminderWidget/BeeminderGoalCountdownWidget.swift @@ -0,0 +1,270 @@ +// +// BeeminderGoalCountdownWidget.swift +// BeeminderWidget +// +// Created by krugerk on 2024-11-13. +// + +import BeeKit +import SwiftUI +import WidgetKit + +struct BeeminderGoalCountdownWidgetProvider: AppIntentTimelineProvider { + func placeholder(in _: Context) -> BeeminderGoalCountdownWidgetEntry { + .init(date: Date(), + configuration: GoalCountdownConfigurationAppIntent(), + updatedAt: Date().addingTimeInterval(-60 * 1000).timeIntervalSince1970, + username: "username", + goalDTO: BeeminderGoalCountdownGoalDTO(name: "goal1", + limSum: "+3 in 3 days", + countdownColor: Color.cyan, + lastTouch: "")) + } + + func snapshot(for _: GoalCountdownConfigurationAppIntent, in context: Context) async -> BeeminderGoalCountdownWidgetEntry { + placeholder(in: context) + } + + func timeline(for configuration: GoalCountdownConfigurationAppIntent, in _: Context) async -> Timeline { + var goal: BeeminderGoalCountdownGoalDTO? { + guard let goalName = configuration.goalName else { return nil } + return usersGoals.first { $0.name.caseInsensitiveCompare(goalName) == .orderedSame } + } + + let updatedAt: TimeInterval? = { + guard let lastTouch = goal?.lastTouch else { return nil } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + + let date = formatter.date(from: lastTouch) + return date?.timeIntervalSince1970 + }() + + let entries: [BeeminderGoalCountdownWidgetEntry] = await [ + BeeminderGoalCountdownWidgetEntry(date: Date(), + configuration: configuration, + updatedAt: updatedAt, + username: username, + goalDTO: goal), + ] + + return Timeline(entries: entries, policy: .atEnd) + } +} + +private extension BeeminderGoalCountdownWidgetProvider { + private var username: String? { + get async { + await ServiceLocator.currentUserManager.username + } + } + + var usersGoals: [BeeminderGoalCountdownGoalDTO] { + ServiceLocator.goalManager + .staleGoals(context: ServiceLocator.persistentContainer.viewContext)? + .sorted(using: SortDescriptor(\.urgencyKey)) + .map(BeeminderGoalCountdownGoalDTO.init) + ?? [] + } +} + +struct BeeminderGoalCountdownGoalDTO { + let name: String + let limSum: String + let countdownColor: Color + let lastTouch: String + + init(goal: Goal) { + self.init(name: goal.slug, + limSum: goal.limSum, + countdownColor: Color(uiColor: goal.countdownColor), + lastTouch: goal.lastTouch) + } + + init(name: String, + limSum: String, + countdownColor: Color, + lastTouch: String) + { + self.name = name + self.limSum = limSum + self.countdownColor = countdownColor + self.lastTouch = lastTouch + } +} + +struct BeeminderGoalCountdownWidgetEntry: TimelineEntry { + var date: Date + + let configuration: GoalCountdownConfigurationAppIntent + + let updatedAt: TimeInterval? + let username: String? + // let goal: Goal? + let goalDTO: BeeminderGoalCountdownGoalDTO? + + var userProvidedGoalName: String? { + configuration.goalName + } + + var appGoalDeepLink: URL { + guard let foundGoalName = goalDTO?.name else { + return URL(string: "beeminder://")! + } + + return URL(string: "beeminder://?slug=\(foundGoalName)")! + } +} + +struct BeeminderGoalCountdownWidgetEntryView: View { + var entry: BeeminderGoalCountdownWidgetProvider.Entry + + var body: some View { + if entry.username == nil { + // no user + ZStack { + Image(systemName: "laser.burst") + .resizable() + .scaledToFit() + .clipShape(.rect(cornerRadius: 8)) + .opacity(0.04) + + VStack { + Text("Sign In") + .font(.title3) + Spacer() + Spacer() + } + } + .shadow(radius: 5) + } else if entry.goalDTO == nil { + // no goal + ZStack { + Image(systemName: "laser.burst") + .resizable() + .scaledToFit() + .clipShape(.rect(cornerRadius: 8)) + .opacity(0.04) + + VStack { + Text("Goal not found") + .font(.title3) + Spacer() + Spacer() + + Text(entry.userProvidedGoalName ?? "Edit Widget") + .font(.title) + .frame(width: .infinity) + .minimumScaleFactor(0.2) + .lineLimit(2) + } + } + } else { + ZStack { + Image(systemName: "chart.bar.xaxis.ascending") + .resizable() + .scaledToFit() + .clipShape(.rect(cornerRadius: 8)) + .opacity(0.04) + + VStack { + if let updatedAt = entry.updatedAt { + Text(Date(timeIntervalSince1970: updatedAt), + format: .dateTime) + .font(.caption2) + .monospaced() + } else { + Text("last updated at: unknown") + .font(.caption2) + .monospaced() + } + + Spacer() + + Text(entry.userProvidedGoalName ?? "Edit Widget") + .font(.title) + .frame(width: .infinity) + .minimumScaleFactor(0.2) + .lineLimit(2) + + if entry.configuration.showLimSum { + Spacer() + Text(entry.goalDTO?.limSum ?? "?") + .font(.caption2) + .monospaced() + } + } + .shadow(radius: 5) + .widgetURL(entry.appGoalDeepLink) + } + } + } +} + +struct BeeminderGoalCountdownWidget: Widget { + let kind: String = "BeeminderGoalCountdownWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: GoalCountdownConfigurationAppIntent.self, provider: BeeminderGoalCountdownWidgetProvider()) { entry in + + var countdownColor: Color? { + guard let countdownColor = entry.goalDTO?.countdownColor else { return nil } + return Color(countdownColor) + } + + let background: Color = countdownColor ?? .accentColor + + BeeminderGoalCountdownWidgetEntryView(entry: entry) + .containerBackground(background, for: .widget) + } + .configurationDisplayName("Goal Countdown") + .description("A single goal in its countdown color") + .supportedFamilies([.systemSmall]) + } +} + +extension GoalCountdownConfigurationAppIntent { + static var steps: GoalCountdownConfigurationAppIntent { + let config = GoalCountdownConfigurationAppIntent() + config.goalName = "steps" + config.showLimSum = true + return config + } + + static var withoutLimSum: GoalCountdownConfigurationAppIntent { + let config = GoalCountdownConfigurationAppIntent() + config.goalName = "dial" + config.showLimSum = false + return config + } +} + +#Preview(as: .systemSmall) { + BeeminderGoalCountdownWidget() +} timeline: { + BeeminderGoalCountdownWidgetEntry(date: .now, + configuration: .steps, + updatedAt: Date().addingTimeInterval(-60 * 1000).timeIntervalSince1970, + username: "user123", + goalDTO: nil) + BeeminderGoalCountdownWidgetEntry(date: .now, + configuration: .withoutLimSum, + updatedAt: Date().addingTimeInterval(-60 * 1000).timeIntervalSince1970, + username: "", + goalDTO: .init(name: "writing", + limSum: "3 pages in 2 days", countdownColor: .cyan, + lastTouch: "")) +} + +#Preview(as: .systemSmall) { + BeeminderGoalCountdownWidget() +} timeline: { + BeeminderGoalCountdownWidgetEntry(date: .now, + configuration: .withoutLimSum, + updatedAt: Date().addingTimeInterval(-60 * 1000).timeIntervalSince1970, + username: "Player1", + goalDTO: .init(name: "writing", + limSum: "3 pages in 2 days", countdownColor: .cyan, lastTouch: "")) +} diff --git a/BeeminderWidget/BeeminderGoalListWidget.swift b/BeeminderWidget/BeeminderGoalListWidget.swift new file mode 100644 index 00000000..68e27541 --- /dev/null +++ b/BeeminderWidget/BeeminderGoalListWidget.swift @@ -0,0 +1,306 @@ +// +// BeeminderGoalListWidget.swift +// BeeminderWidget +// +// Created by krugerk on 2024-11-13. +// + +import BeeKit +import SwiftUI +import WidgetKit + +struct BeeminderGoalListProvider: TimelineProvider { + func placeholder(in context: Context) -> BeeminderGoalListEntry { + let min = context.family == .systemLarge ? 3 : 1 + let numGoals = Int.random(in: min ... 7) + + let goals = !usersGoals.isEmpty + ? usersGoals + : BeeminderGoalListEntryGoalDTO.goalDTOs.shuffled() + + return .init(date: Date(), + username: "Player1", + goals: goals.prefix(numGoals).map { $0 }) + } + + func getSnapshot(in context: Context, completion: @escaping @Sendable (BeeminderGoalListEntry) -> Void) { + completion(placeholder(in: context)) + } + + func getTimeline(in _: Context, completion: @escaping @Sendable (Timeline) -> Void) { + Task { + let entries: [BeeminderGoalListEntry] = await [ + .init(date: Date(), + username: username, + goals: usersGoals), + ] + + let timeline = Timeline(entries: entries, policy: .atEnd) + + completion(timeline) + } + + } +} + +private extension BeeminderGoalListProvider { + private var username: String? { + get async { + await ServiceLocator.currentUserManager.username + } + } + + private var usersGoals: [BeeminderGoalListEntryGoalDTO] { + ServiceLocator.goalManager + .staleGoals(context: ServiceLocator.persistentContainer.viewContext)? + .sorted(using: SortDescriptor(\.urgencyKey)) + .map(BeeminderGoalListEntryGoalDTO.init) + ?? [] + } +} + +struct BeeminderGoalListEntry: TimelineEntry { + var date: Date + + let username: String? + + let goals: [BeeminderGoalListEntryGoalDTO] +} + +struct BeeminderGoalListEntryGoalDTO { + let id: String + let name: String + let limSum: String + let urgencyKey: String + let countdownColor: Color + + var appLink: URL { + URL(string: "beeminder://?slug=\(name)")! + } + + init(goal: Goal) { + self.init(id: goal.id, + name: goal.slug, + limSum: goal.limSum, + urgencyKey: goal.urgencyKey, + countdownColor: Color(uiColor: goal.countdownColor)) + } + + init(id: String, name: String, limSum: String, urgencyKey: String, countdownColor: Color) { + self.id = id + self.name = name + self.limSum = limSum + self.urgencyKey = urgencyKey + self.countdownColor = countdownColor + } +} + +struct BeeminderGoalListWidgetEntryView: View { + @Environment(\.widgetFamily) var family + + var entry: BeeminderGoalListProvider.Entry + + private var goalLimit: Int { + return switch family { + case .systemSmall: + 0 + case .systemMedium: + 3 + case .systemLarge: + 7 + case .systemExtraLarge: + 7 + case .accessoryCircular: + 0 + case .accessoryRectangular: + 0 + case .accessoryInline: + 0 + @unknown default: + 0 + } + } + + private var goalsToDisplay: [BeeminderGoalListEntryGoalDTO] { + entry.goals + .sorted(using: SortDescriptor(\.urgencyKey)) + .prefix(goalLimit) + .map { $0 } + } + + var errorMessage: String? { + if entry.username == nil { + return "Sign In" + } else if goalsToDisplay.isEmpty { + return "No Goals" + } else { + return nil + } + } + + var body: some View { + if let errorMessage { + // no user + ZStack { + Image(systemName: "laser.burst") + .resizable() + .scaledToFit() + .clipShape(.rect(cornerRadius: 8)) + .opacity(0.04) + + VStack { + Spacer() + + Text(errorMessage) + .font(.title3) + + Spacer() + Text("List of goals' amounts due, sorted by urgency") + .font(.caption) + .italic() + } + } + .shadow(radius: 5) + } else { + ZStack { + Image(systemName: "chart.dots.scatter") + .resizable() + .scaledToFit() + .clipShape(.rect(cornerRadius: 8)) + .opacity(0.04) + + GroupBox { + ForEach(goalsToDisplay, id: \.id) { goal in + Link(destination: goal.appLink) { + LabeledContent(goal.limSum, value: goal.name) + } + .padding(8) + .background(goal.countdownColor.gradient.opacity(0.2)) + .clipShape( + // rounded background + RoundedRectangle(cornerRadius: 2, style: .circular) + ) + .overlay( + // rounded border + RoundedRectangle(cornerRadius: 2) + .stroke(goal.countdownColor.gradient, lineWidth: 2) + ) + } + } + .backgroundStyle(.clear) + } + } + } +} + +struct BeeminderGoalListWidget: Widget { + let kind: String = "BeeminderGoalListWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, + provider: BeeminderGoalListProvider()) + { entry in + BeeminderGoalListWidgetEntryView(entry: entry) + .containerBackground(Color.clear, + for: .widget) + } + .configurationDisplayName("Goal List") + .description("Displays goals sorted by urgency") + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +private extension BeeminderGoalListEntryGoalDTO { + static var goalDTOs: [BeeminderGoalListEntryGoalDTO] { + [ + .init(id: "id-011", + name: "dial", + limSum: "+1 due tomorrow 11:00", + urgencyKey: "urgency-011", + countdownColor: .red), + .init(id: "id-022", + name: "teeth", + limSum: "+2 due tomorrow 23:59", + urgencyKey: "urgency-022", + countdownColor: .red), + .init(id: "id-111", + name: "jogging", + limSum: "+5km within 4 days", + urgencyKey: "urgency-111", + countdownColor: .indigo), + .init(id: "id-999", + name: "goalname", + limSum: "-421 in 431 days", + urgencyKey: "urgency-999", + countdownColor: .green), + .init(id: "id-222", + name: "steps", + limSum: "+10k this week", + urgencyKey: "urgency-222", + countdownColor: .cyan), + .init(id: "id-888", + name: "productivity", + limSum: "+1.15032 due in 54 days", + urgencyKey: "urgency-888", + countdownColor: .green), + .init(id: "id-765", + name: "sleep_hours", + limSum: "+8h in 7 days", + urgencyKey: "urgency-765", + countdownColor: .orange), + .init(id: "id-745", + name: "readingbee2", + limSum: "limit +1 today, safe until Sat", + urgencyKey: "urgency-745", + countdownColor: .orange), + ] + } +} + +#Preview(as: .systemMedium) { + BeeminderGoalListWidget() +} timeline: { + let goals = BeeminderGoalListEntryGoalDTO.goalDTOs + .prefix(10) + .map { $0 } + + BeeminderGoalListEntry(date: .now, + username: "User531", + goals: goals) +} + +#Preview(as: .systemLarge) { + BeeminderGoalListWidget() +} timeline: { + let goals = BeeminderGoalListEntryGoalDTO.goalDTOs + .prefix(10) + .map { $0 } + + BeeminderGoalListEntry(date: .now, + username: "captain", + goals: goals) +} + +#Preview(as: .systemLarge) { + BeeminderGoalListWidget() +} timeline: { + BeeminderGoalListEntry(date: .now, + username: nil, + goals: []) +} + +#Preview(as: .systemMedium) { + BeeminderGoalListWidget() +} timeline: { + BeeminderGoalListEntry(date: .now, + username: nil, + goals: []) +} + +#Preview(as: .systemMedium) { + BeeminderGoalListWidget() +} timeline: { + BeeminderGoalListEntry(date: .now, + username: "someUser", + goals: []) +} diff --git a/BeeminderWidget/BeeminderPledgedTodayWidget.swift b/BeeminderWidget/BeeminderPledgedTodayWidget.swift new file mode 100644 index 00000000..4d642092 --- /dev/null +++ b/BeeminderWidget/BeeminderPledgedTodayWidget.swift @@ -0,0 +1,140 @@ + +// +// BeeminderPledgedTodayWidget.swift +// BeeminderWidget +// +// Created by krugerk on 2024-11-13. +// + +import BeeKit +import SwiftUI +import WidgetKit + +struct BeeminderPledgedTodayWidgetProvider: AppIntentTimelineProvider { + func placeholder(in _: Context) -> BeeminderPledgedTodayEntry { + BeeminderPledgedTodayEntry(date: Date(), + configuration: .honeyMoneyConfig, + pledges: usersGoals.map(\.pledge)) + } + + func snapshot(for configuration: PledgedConfigurationAppIntent, in _: Context) async -> BeeminderPledgedTodayEntry { + BeeminderPledgedTodayEntry(date: Date(), + configuration: configuration, + pledges: usersGoals.map(\.pledge)) + } + + func timeline(for configuration: PledgedConfigurationAppIntent, in _: Context) async -> Timeline { + let pledges = usersGoals.map(\.pledge) + + let entries: [BeeminderPledgedTodayEntry] = [ + .init(date: Date(), + configuration: configuration, + pledges: pledges), + ] + + return Timeline(entries: entries, policy: .atEnd) + } +} + +private extension BeeminderPledgedTodayWidgetProvider { + private var username: String? { + get async { + await ServiceLocator.currentUserManager.username + } + } + + private var usersGoals: [Goal] { + ServiceLocator.goalManager + .staleGoals(context: ServiceLocator.persistentContainer.newBackgroundContext())? + .filter { !$0.won } + .sorted(using: SortDescriptor(\.urgencyKey)) + ?? [] + } +} + +struct BeeminderPledgedTodayEntry: TimelineEntry { + var date: Date + + let configuration: PledgedConfigurationAppIntent + + let pledges: [Int] + + var amountPledged: Int { + pledges.reduce(0, +) + } + + var denomination: String { + switch configuration.denomination { + case .honeyMoney: return "H$" + case .usDollar: return "$" + } + } +} + +struct BeeminderPledgedTodayWidgetEntryView: View { + @Environment(\.widgetFamily) var family + + var entry: BeeminderPledgedTodayWidgetProvider.Entry + + var body: some View { + ZStack { + Image(systemName: "banknote") + .resizable() + .scaledToFill() + .foregroundColor(.yellow) + .padding() + .opacity(0.2) + + HStack { + Text(entry.denomination) + Text(entry.amountPledged, format: .number) + } + .font(.largeTitle) + .bold() + .fontDesign(.rounded) + .foregroundColor(.yellow) + } + } +} + +struct BeeminderPledgedTodayWidget: Widget { + let kind: String = "BeeminderPledgedTodayWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: PledgedConfigurationAppIntent.self, + provider: BeeminderPledgedTodayWidgetProvider()) + { entry in + BeeminderPledgedTodayWidgetEntryView(entry: entry) + .containerBackground(.black, for: .widget) + } + .configurationDisplayName("Amount Pledged") + .description("Displays the amount currently at stake across all of the user's active goals") + .supportedFamilies([.systemSmall]) + } +} + +extension PledgedConfigurationAppIntent { + static var honeyMoneyConfig: Self { + let config = PledgedConfigurationAppIntent() + config.denomination = .honeyMoney + return config + } + + static var usdConfig: Self { + let config = PledgedConfigurationAppIntent() + config.denomination = .usDollar + return config + } +} + +#Preview(as: .systemSmall) { + BeeminderPledgedTodayWidget() +} timeline: { + BeeminderPledgedTodayEntry(date: .now, + configuration: .honeyMoneyConfig, + pledges: [7]) + BeeminderPledgedTodayEntry(date: .now, + configuration: .usdConfig, + pledges: [137]) +} diff --git a/BeeminderWidget/BeeminderWidgetBundle.swift b/BeeminderWidget/BeeminderWidgetBundle.swift new file mode 100644 index 00000000..e93832cb --- /dev/null +++ b/BeeminderWidget/BeeminderWidgetBundle.swift @@ -0,0 +1,20 @@ +// +// BeeminderWidgetBundle.swift +// BeeminderWidget +// +// Created by krugerk on 2024-11-13. +// + +import SwiftUI +import WidgetKit + +@main +struct BeeminderWidgetBundle: WidgetBundle { + var body: some Widget { + BeeminderGoalCountdownWidget() + + BeeminderGoalListWidget() + + BeeminderPledgedTodayWidget() + } +} diff --git a/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift b/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift new file mode 100644 index 00000000..29e7d40e --- /dev/null +++ b/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift @@ -0,0 +1,63 @@ +// +// BeeminderWidgetConfigurationIntents.swift +// BeeminderWidget +// +// Created by krugerk on 2024-11-13. +// + +import AppIntents +import BeeKit +import WidgetKit + +struct GoalBasicsConfigurationAppIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Goal Basics" + static let description = IntentDescription("Shows basic data of a goal!") + + @Parameter(title: "Goal Name (aka slug)", + default: "steps", + inputOptions: String.IntentInputOptions(keyboardType: .default, capitalizationType: .none, autocorrect: false)) + var goalName: String +} + +struct PledgedConfigurationAppIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Amount Pledged" + static let description = IntentDescription("Shows the sum you currently have pledged!") + + enum BeeminderPledgeDenomination: String, AppEnum { + case honeyMoney, usDollar + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Pledge Denomination" + static let caseDisplayRepresentations: [BeeminderPledgeDenomination: DisplayRepresentation] = [ + .honeyMoney: "Honey Money", + .usDollar: "US Dollar", + ] + } + + @Parameter(title: "Denomination", default: .honeyMoney) + var denomination: BeeminderPledgeDenomination +} + +struct GoalCountdownConfigurationAppIntent: AppIntent, WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Colored Goal" + static let description = IntentDescription("Shows a goal in its countdown color!") + + @Parameter(title: "Goal Name (aka slug)", + optionsProvider: BeeminderGoalCountdownWidgetProvider.GoalNameProvider()) + var goalName: String? + + @Parameter(title: "Show Summary of what you need to do to eke by, e.g., \"+2 within 1 day\".", default: true) + var showLimSum: Bool +} + +// namespace? +extension BeeminderGoalCountdownWidgetProvider { + struct GoalNameProvider: DynamicOptionsProvider { + func results() async throws -> [String] { + ServiceLocator.goalManager + .staleGoals(context: ServiceLocator.persistentContainer.viewContext)? + .sorted(using: SortDescriptor(\.slug)) + .map { $0.slug } + ?? [] + } + } +} diff --git a/BeeminderWidget/BeeminderWidgetExtension.entitlements b/BeeminderWidget/BeeminderWidgetExtension.entitlements new file mode 100644 index 00000000..315989a2 --- /dev/null +++ b/BeeminderWidget/BeeminderWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.beeminder.beeminder + + + diff --git a/BeeminderWidget/Info.plist b/BeeminderWidget/Info.plist new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/BeeminderWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/README.md b/README.md index ea84c73c..da775c57 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Official Beeminder for iOS app ## Features - native iOS app - [Apple Health integration](#apple-health-integration) - - today widget, displaying up to three goals and allowing quick data entry + - widgets, for the home screen - gallery view of all of a user's active goals - facilitates viewing one's goals and their status - provides notifications of pending goal deadlines