From 7dbe167191df3ce7fea89871eceee6da7bc3abf4 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Wed, 13 Nov 2024 18:34:03 +0100 Subject: [PATCH 1/4] introduces some iOS 14 widgets! bundle - for allowing for more than one widget widgets: - goal countdown: displays a goal in its countdown color - goal list: list of goals, sorted by urgency - on the line: sum of pledge amount the app already handled the url beeminder://?slug=goalname to open the goalvc of the corresponding goal and the widgets use this breaks the ice, fixes: #68 --- BeeKit/Managers/CurrentUserManager.swift | 6 + BeeKit/Managers/GoalManager.swift | 9 +- BeeSwift.xcodeproj/project.pbxproj | 270 +++++++++++++++- .../BeeminderWidgetExtension.xcscheme | 71 +++++ BeeSwift/AppDelegate.swift | 8 + BeeSwift/BackgroundUpdates.swift | 0 BeeSwift/Gallery/GalleryViewController.swift | 2 +- BeeSwift/Info.plist | 7 - .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ BeeminderWidget/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + .../BeeminderGoalCountdownWidget.swift | 264 +++++++++++++++ BeeminderWidget/BeeminderGoalListWidget.swift | 301 ++++++++++++++++++ .../BeeminderPledgedTodayWidget.swift | 138 ++++++++ BeeminderWidget/BeeminderWidgetBundle.swift | 20 ++ .../BeeminderWidgetConfigurationIntents.swift | 51 +++ .../BeeminderWidgetExtension.entitlements | 10 + BeeminderWidget/Info.plist | 11 + 19 files changed, 1219 insertions(+), 12 deletions(-) create mode 100644 BeeSwift.xcodeproj/xcshareddata/xcschemes/BeeminderWidgetExtension.xcscheme mode change 100644 => 100755 BeeSwift/BackgroundUpdates.swift create mode 100644 BeeminderWidget/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 BeeminderWidget/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 BeeminderWidget/Assets.xcassets/Contents.json create mode 100644 BeeminderWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 BeeminderWidget/BeeminderGoalCountdownWidget.swift create mode 100644 BeeminderWidget/BeeminderGoalListWidget.swift create mode 100644 BeeminderWidget/BeeminderPledgedTodayWidget.swift create mode 100644 BeeminderWidget/BeeminderWidgetBundle.swift create mode 100644 BeeminderWidget/BeeminderWidgetConfigurationIntents.swift create mode 100644 BeeminderWidget/BeeminderWidgetExtension.entitlements create mode 100644 BeeminderWidget/Info.plist 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..43362fe2 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,7 +445,6 @@ 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 { 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..b7b875b1 --- /dev/null +++ b/BeeminderWidget/BeeminderGoalCountdownWidget.swift @@ -0,0 +1,264 @@ +// +// BeeminderGoalCountdownWidget.swift +// BeeminderWidget +// +// Created by krugerk on 2024-11-13. +// + +import BeeKit +import SwiftUI +import WidgetKit + +struct BeeminderGoalCountdownWidgetProvider: AppIntentTimelineProvider { + func placeholder(in _: Context) -> BeeminderGoalCountdownWidgetEntry { + let goalDTO = usersGoals.randomElement() + + return .init(date: Date(), + configuration: GoalCountdownConfigurationAppIntent(), + updatedAt: Date().addingTimeInterval(-60 * 1000).timeIntervalSince1970, + username: username, + goalDTO: goalDTO) + } + + func snapshot(for _: GoalCountdownConfigurationAppIntent, in context: Context) async -> BeeminderGoalCountdownWidgetEntry { + placeholder(in: context) + } + + func timeline(for configuration: GoalCountdownConfigurationAppIntent, in _: Context) async -> Timeline { + let goal = usersGoals.first { $0.name.caseInsensitiveCompare(configuration.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] = [ + BeeminderGoalCountdownWidgetEntry(date: Date(), + configuration: configuration, + updatedAt: updatedAt, + username: username, + goalDTO: goal), + ] + + return Timeline(entries: entries, policy: .atEnd) + } +} + +private extension BeeminderGoalCountdownWidgetProvider { + private var username: String? { + ServiceLocator.currentUserManager.username + } + + private 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) + .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) + .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..d6b31793 --- /dev/null +++ b/BeeminderWidget/BeeminderGoalListWidget.swift @@ -0,0 +1,301 @@ +// +// 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: username, + 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) { + let entries: [BeeminderGoalListEntry] = [ + .init(date: Date(), + username: username, + goals: usersGoals), + ] + + let timeline = Timeline(entries: entries, policy: .atEnd) + + completion(timeline) + } +} + +private extension BeeminderGoalListProvider { + private var username: String? { + 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..6c97b25f --- /dev/null +++ b/BeeminderWidget/BeeminderPledgedTodayWidget.swift @@ -0,0 +1,138 @@ + +// +// 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? { + 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..353d2b65 --- /dev/null +++ b/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift @@ -0,0 +1,51 @@ +// +// 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: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Colored Goal" + static let description = IntentDescription("Shows a goal in its countdown color!") + + @Parameter(title: "Goal Name (aka slug)", + default: "dial", + inputOptions: String.IntentInputOptions(keyboardType: .default, capitalizationType: .none, autocorrect: false)) + 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 +} 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 + + + From ee8ea36562a41474c1f84d29158b259b5aa8df53 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 16 Nov 2024 02:04:49 +0100 Subject: [PATCH 2/4] Update BeeminderWidget/BeeminderGoalListWidget.swift --- BeeminderWidget/BeeminderGoalListWidget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BeeminderWidget/BeeminderGoalListWidget.swift b/BeeminderWidget/BeeminderGoalListWidget.swift index d6b31793..50e39bfc 100644 --- a/BeeminderWidget/BeeminderGoalListWidget.swift +++ b/BeeminderWidget/BeeminderGoalListWidget.swift @@ -19,7 +19,7 @@ struct BeeminderGoalListProvider: TimelineProvider { : BeeminderGoalListEntryGoalDTO.goalDTOs.shuffled() return .init(date: Date(), - username: username, + username: username ?? "Player1", goals: goals.prefix(numGoals).map { $0 }) } From fbb929beeeaa14f32e49e2762caed9ec4a8238a3 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 16 Nov 2024 15:42:27 +0100 Subject: [PATCH 3/4] README.md update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 79bd443d4b03c7264108b804f89633ec416a7ea7 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Thu, 21 Nov 2024 23:03:20 +0100 Subject: [PATCH 4/4] dynamic list of goal names for countdown widget todo: handle optional goalname --- BeeSwift/Gallery/GalleryViewController.swift | 4 +-- .../BeeminderGoalCountdownWidget.swift | 34 +++++++++++-------- BeeminderWidget/BeeminderGoalListWidget.swift | 23 ++++++++----- .../BeeminderPledgedTodayWidget.swift | 4 ++- .../BeeminderWidgetConfigurationIntents.swift | 20 ++++++++--- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/BeeSwift/Gallery/GalleryViewController.swift b/BeeSwift/Gallery/GalleryViewController.swift index 43362fe2..083e011c 100644 --- a/BeeSwift/Gallery/GalleryViewController.swift +++ b/BeeSwift/Gallery/GalleryViewController.swift @@ -447,12 +447,12 @@ class GalleryViewController: UIViewController, UICollectionViewDelegateFlowLayou @objc func openGoalFromNotification(_ notification: Notification) { 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/BeeminderWidget/BeeminderGoalCountdownWidget.swift b/BeeminderWidget/BeeminderGoalCountdownWidget.swift index b7b875b1..46c015bd 100644 --- a/BeeminderWidget/BeeminderGoalCountdownWidget.swift +++ b/BeeminderWidget/BeeminderGoalCountdownWidget.swift @@ -11,13 +11,14 @@ import WidgetKit struct BeeminderGoalCountdownWidgetProvider: AppIntentTimelineProvider { func placeholder(in _: Context) -> BeeminderGoalCountdownWidgetEntry { - let goalDTO = usersGoals.randomElement() - - return .init(date: Date(), - configuration: GoalCountdownConfigurationAppIntent(), - updatedAt: Date().addingTimeInterval(-60 * 1000).timeIntervalSince1970, - username: username, - goalDTO: goalDTO) + .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 { @@ -25,7 +26,10 @@ struct BeeminderGoalCountdownWidgetProvider: AppIntentTimelineProvider { } func timeline(for configuration: GoalCountdownConfigurationAppIntent, in _: Context) async -> Timeline { - let goal = usersGoals.first { $0.name.caseInsensitiveCompare(configuration.goalName) == .orderedSame } + 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 } @@ -38,7 +42,7 @@ struct BeeminderGoalCountdownWidgetProvider: AppIntentTimelineProvider { return date?.timeIntervalSince1970 }() - let entries: [BeeminderGoalCountdownWidgetEntry] = [ + let entries: [BeeminderGoalCountdownWidgetEntry] = await [ BeeminderGoalCountdownWidgetEntry(date: Date(), configuration: configuration, updatedAt: updatedAt, @@ -52,10 +56,12 @@ struct BeeminderGoalCountdownWidgetProvider: AppIntentTimelineProvider { private extension BeeminderGoalCountdownWidgetProvider { private var username: String? { - ServiceLocator.currentUserManager.username + get async { + await ServiceLocator.currentUserManager.username + } } - private var usersGoals: [BeeminderGoalCountdownGoalDTO] { + var usersGoals: [BeeminderGoalCountdownGoalDTO] { ServiceLocator.goalManager .staleGoals(context: ServiceLocator.persistentContainer.viewContext)? .sorted(using: SortDescriptor(\.urgencyKey)) @@ -99,7 +105,7 @@ struct BeeminderGoalCountdownWidgetEntry: TimelineEntry { // let goal: Goal? let goalDTO: BeeminderGoalCountdownGoalDTO? - var userProvidedGoalName: String { + var userProvidedGoalName: String? { configuration.goalName } @@ -148,7 +154,7 @@ struct BeeminderGoalCountdownWidgetEntryView: View { Spacer() Spacer() - Text(entry.userProvidedGoalName) + Text(entry.userProvidedGoalName ?? "Edit Widget") .font(.title) .frame(width: .infinity) .minimumScaleFactor(0.2) @@ -177,7 +183,7 @@ struct BeeminderGoalCountdownWidgetEntryView: View { Spacer() - Text(entry.userProvidedGoalName) + Text(entry.userProvidedGoalName ?? "Edit Widget") .font(.title) .frame(width: .infinity) .minimumScaleFactor(0.2) diff --git a/BeeminderWidget/BeeminderGoalListWidget.swift b/BeeminderWidget/BeeminderGoalListWidget.swift index 50e39bfc..68e27541 100644 --- a/BeeminderWidget/BeeminderGoalListWidget.swift +++ b/BeeminderWidget/BeeminderGoalListWidget.swift @@ -19,7 +19,7 @@ struct BeeminderGoalListProvider: TimelineProvider { : BeeminderGoalListEntryGoalDTO.goalDTOs.shuffled() return .init(date: Date(), - username: username ?? "Player1", + username: "Player1", goals: goals.prefix(numGoals).map { $0 }) } @@ -28,21 +28,26 @@ struct BeeminderGoalListProvider: TimelineProvider { } func getTimeline(in _: Context, completion: @escaping @Sendable (Timeline) -> Void) { - let entries: [BeeminderGoalListEntry] = [ - .init(date: Date(), - username: username, - goals: usersGoals), - ] + Task { + let entries: [BeeminderGoalListEntry] = await [ + .init(date: Date(), + username: username, + goals: usersGoals), + ] - let timeline = Timeline(entries: entries, policy: .atEnd) + let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) + completion(timeline) + } + } } private extension BeeminderGoalListProvider { private var username: String? { - ServiceLocator.currentUserManager.username + get async { + await ServiceLocator.currentUserManager.username + } } private var usersGoals: [BeeminderGoalListEntryGoalDTO] { diff --git a/BeeminderWidget/BeeminderPledgedTodayWidget.swift b/BeeminderWidget/BeeminderPledgedTodayWidget.swift index 6c97b25f..4d642092 100644 --- a/BeeminderWidget/BeeminderPledgedTodayWidget.swift +++ b/BeeminderWidget/BeeminderPledgedTodayWidget.swift @@ -38,7 +38,9 @@ struct BeeminderPledgedTodayWidgetProvider: AppIntentTimelineProvider { private extension BeeminderPledgedTodayWidgetProvider { private var username: String? { - ServiceLocator.currentUserManager.username + get async { + await ServiceLocator.currentUserManager.username + } } private var usersGoals: [Goal] { diff --git a/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift b/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift index 353d2b65..29e7d40e 100644 --- a/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift +++ b/BeeminderWidget/BeeminderWidgetConfigurationIntents.swift @@ -37,15 +37,27 @@ struct PledgedConfigurationAppIntent: WidgetConfigurationIntent { var denomination: BeeminderPledgeDenomination } -struct GoalCountdownConfigurationAppIntent: WidgetConfigurationIntent { +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)", - default: "dial", - inputOptions: String.IntentInputOptions(keyboardType: .default, capitalizationType: .none, autocorrect: false)) - var goalName: String + 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 } + ?? [] + } + } +}