diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index b162633e0..9a60721bf 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,12 +7,24 @@ objects = { /* Begin PBXBuildFile section */ + F85CF0901B0E0FC11036A6A7 /* GlucoseChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */; }; + 0D89C9E0A8B6D172D0D08983 /* GlucoseChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */; }; + F19C8BD4E2827EC60DF73123 /* NightscoutChartFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F1C3BFC5A3378CE36AF953 /* NightscoutChartFetcher.swift */; }; + 8AD21902935AAD4A6A72AECF /* GlucoseSlotFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */; }; + 3CB7CE68C5C08EF95C7EFD5D /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 7552252D0D0BDD20AFBB1FD1 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + D49B0D6E8CC25401E7D0043E /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + E3E72899F78FE1ACDC01E628 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + 135CC03ED32EAA2767137092 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 9E3E4F99E303C250FC72C969 /* LoopFollowWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 104429F718054304E72C8C77 /* LoopFollowWidget.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; + 30EBBFA8E833254472B5A8E7 /* GlucoseSlotFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */; }; + DE4C84A4F8C5789D10A5B2B9 /* GlucoseSlotFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; @@ -458,6 +470,13 @@ remoteGlobalIDString = FC9788132485969B00A7906C; remoteInfo = LoopFollow; }; + 9274652115971A7691241ECC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1AE4F08CDE3EF443E163CF94; + remoteInfo = LoopFollowWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -468,6 +487,7 @@ dstSubfolderSpec = 13; files = ( 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */, + 9E3E4F99E303C250FC72C969 /* LoopFollowWidget.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -483,6 +503,11 @@ 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; + A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSlotFormat.swift; sourceTree = ""; }; + 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartSeries.swift; sourceTree = ""; }; + E8F1C3BFC5A3378CE36AF953 /* NightscoutChartFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutChartFetcher.swift; sourceTree = ""; }; + 294EE06A684C3BBBF8702FCC /* LoopFollowWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowWidgetExtension.entitlements; sourceTree = ""; }; + 104429F718054304E72C8C77 /* LoopFollowWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; @@ -919,6 +944,7 @@ 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; + EE2CBE5FC6FDD612F92C5DAE /* LoopFollowWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowWidget; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -948,6 +974,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CDF61235460EA5C9790F7D0E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -965,6 +998,9 @@ 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */, 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, + A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */, + 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */, + E8F1C3BFC5A3378CE36AF953 /* NightscoutChartFetcher.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, ); path = LiveActivity; @@ -1670,6 +1706,7 @@ children = ( 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, + 294EE06A684C3BBBF8702FCC /* LoopFollowWidgetExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, DDB0AF4F2BB1A81F00AFA48B /* Scripts */, @@ -1679,6 +1716,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + EE2CBE5FC6FDD612F92C5DAE /* LoopFollowWidget */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1691,6 +1729,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + 104429F718054304E72C8C77 /* LoopFollowWidget.appex */, ); name = Products; sourceTree = ""; @@ -1831,6 +1870,7 @@ ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + 0BA3A403A151D51F96333506 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1844,6 +1884,28 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; + 1AE4F08CDE3EF443E163CF94 /* LoopFollowWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A4061ACE56570735F8A5947A /* Build configuration list for PBXNativeTarget "LoopFollowWidgetExtension" */; + buildPhases = ( + 89F80DFEAC27B3727FE4BB09 /* Sources */, + CDF61235460EA5C9790F7D0E /* Frameworks */, + 5690D24475F3FC5A0A89FC92 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + EE2CBE5FC6FDD612F92C5DAE /* LoopFollowWidget */, + ); + name = LoopFollowWidgetExtension; + packageProductDependencies = ( + ); + productName = LoopFollowWidgetExtension; + productReference = 104429F718054304E72C8C77 /* LoopFollowWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1857,6 +1919,9 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + 1AE4F08CDE3EF443E163CF94 = { + CreatedOnToolsVersion = 26.2; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -1885,6 +1950,7 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + 1AE4F08CDE3EF443E163CF94 /* LoopFollowWidgetExtension */, ); }; /* End PBXProject section */ @@ -2033,6 +2099,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5690D24475F3FC5A0A89FC92 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2130,6 +2203,7 @@ 37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */, 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, + DE4C84A4F8C5789D10A5B2B9 /* GlucoseSlotFormat.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2160,6 +2234,8 @@ 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */, 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */, 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */, + 30EBBFA8E833254472B5A8E7 /* GlucoseSlotFormat.swift in Sources */, + F85CF0901B0E0FC11036A6A7 /* GlucoseChartSeries.swift in Sources */, 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */, DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */, DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */, @@ -2446,6 +2522,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 89F80DFEAC27B3727FE4BB09 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E3E72899F78FE1ACDC01E628 /* GlucoseConversion.swift in Sources */, + 3CB7CE68C5C08EF95C7EFD5D /* AppGroupID.swift in Sources */, + 7552252D0D0BDD20AFBB1FD1 /* GlucoseSnapshot.swift in Sources */, + D49B0D6E8CC25401E7D0043E /* LAAppGroupSettings.swift in Sources */, + 8AD21902935AAD4A6A72AECF /* GlucoseSlotFormat.swift in Sources */, + 135CC03ED32EAA2767137092 /* GlucoseSnapshotStore.swift in Sources */, + 0D89C9E0A8B6D172D0D08983 /* GlucoseChartSeries.swift in Sources */, + F19C8BD4E2827EC60DF73123 /* NightscoutChartFetcher.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2460,6 +2551,12 @@ target = FC9788132485969B00A7906C /* LoopFollow */; targetProxy = DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */; }; + 0BA3A403A151D51F96333506 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 1AE4F08CDE3EF443E163CF94 /* LoopFollowWidgetExtension */; + targetProxy = 9274652115971A7691241ECC /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2812,6 +2909,111 @@ }; name = Release; }; + 40CE5E863B3ECE08CBB62C3D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWidget/WidgetInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + OTHER_LDFLAGS = "$(SDKROOT)/System/Library/Frameworks/Charts.framework/Charts.tbd"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowWidget"; + PRODUCT_MODULE_NAME = LoopFollowWidget; + PRODUCT_NAME = LoopFollowWidget; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + CBB59BAE4150CBC089990558 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWidget/WidgetInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + OTHER_LDFLAGS = "$(SDKROOT)/System/Library/Frameworks/Charts.framework/Charts.tbd"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowWidget"; + PRODUCT_MODULE_NAME = LoopFollowWidget; + PRODUCT_NAME = LoopFollowWidget; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2851,6 +3053,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A4061ACE56570735F8A5947A /* Build configuration list for PBXNativeTarget "LoopFollowWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 40CE5E863B3ECE08CBB62C3D /* Debug */, + CBB59BAE4150CBC089990558 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCVersionGroup section */ diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 3f5aa84ec..5743d0110 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -194,7 +194,8 @@ class AlarmManager { if let actionTitle = actionTitle { let action = UNNotificationAction(identifier: "snooze", title: actionTitle, options: []) let category = UNNotificationCategory(identifier: "category", actions: [action], intentIdentifiers: [], options: []) - UNUserNotificationCenter.current().setNotificationCategories([category]) + // setNotificationCategories replaces the whole set, so include the base categories too. + UNUserNotificationCenter.current().setNotificationCategories(Set(AppDelegate.baseNotificationCategories() + [category])) } } } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 6c9d8e884..039009dde 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -9,6 +9,53 @@ import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate { let notificationCenter = UNUserNotificationCenter.current() + /// Category Trio sets on a "recommended bolus" response; must match Trio's identifier. + static let recommendedBolusCategoryIdentifier = "TRIO_RECOMMENDED_BOLUS" + /// Action on that category that opens the bolus screen pre-filled for review. + static let reviewBolusActionIdentifier = "REVIEW_BOLUS" + + /// The app's full set of notification categories. Single source of truth so every caller of + /// setNotificationCategories (which replaces the whole set) registers all of them — otherwise a caller + /// that sets only its own category silently de-registers the others. + static func baseNotificationCategories() -> [UNNotificationCategory] { + let openAction = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) + let backgroundCategory = UNNotificationCategory( + identifier: BackgroundAlertIdentifier.categoryIdentifier, + actions: [openAction], + intentIdentifiers: [], + options: [] + ) + + // Category for a Trio "recommended bolus" response. The Review action (and tapping the body) opens + // the bolus screen pre-filled; .authenticationRequired keeps it behind device unlock. + let reviewAction = UNNotificationAction( + identifier: reviewBolusActionIdentifier, + title: "Review", + options: [.foreground, .authenticationRequired] + ) + let recommendedBolusCategory = UNNotificationCategory( + identifier: recommendedBolusCategoryIdentifier, + actions: [reviewAction], + intentIdentifiers: [], + options: [] + ) + + return [backgroundCategory, recommendedBolusCategory] + } + + /// Removes any delivered "recommended bolus" notifications so a recommendation can't be tapped or + /// confirmed twice from Notification Center after it has been acted on or has expired. + static func removeDeliveredRecommendedBolusNotifications() { + UNUserNotificationCenter.current().getDeliveredNotifications { notifications in + let identifiers = notifications + .filter { $0.request.content.categoryIdentifier == recommendedBolusCategoryIdentifier } + .map { $0.request.identifier } + if !identifiers.isEmpty { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + } + } + } + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() @@ -29,9 +76,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) - let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) - UNUserNotificationCenter.current().setNotificationCategories([category]) + UNUserNotificationCenter.current().setNotificationCategories(Set(AppDelegate.baseNotificationCategories())) UNUserNotificationCenter.current().delegate = self @@ -191,9 +236,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AlarmManager.shared.performSnooze() } + // A Trio "recommended bolus" review: tapping the body or the Review action opens the bolus screen + // pre-filled; the user still confirms via Face ID and the guardrails, so nothing is dosed here. + if response.actionIdentifier == AppDelegate.reviewBolusActionIdentifier + || response.actionIdentifier == UNNotificationDefaultActionIdentifier + { + if let request = AppDelegate.reviewBolusRequest(from: response.notification.request.content.userInfo) { + // Presented from the tab root; MainTabView defers behind the first-launch consent sheet. + DispatchQueue.main.async { + Observable.shared.pendingReviewBolus.value = request + } + } + } + completionHandler() } + /// Builds a review request from Trio's structured `recommended_bolus` field (units) and the response's + /// `timestamp` (send time). APNs delivers JSON numbers as NSNumber; strings are accepted defensively. + /// Returns nil when the amount is absent or not positive. + static func reviewBolusRequest(from userInfo: [AnyHashable: Any]) -> ReviewBolusRequest? { + func double(_ value: Any?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + return nil + } + + guard let amount = double(userInfo["recommended_bolus"]), amount > 0 else { return nil } + let sentAt = double(userInfo["timestamp"]) ?? Date().timeIntervalSince1970 + return ReviewBolusRequest(amount: amount, sentAt: sentAt) + } + func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask { let forcePortrait = Storage.shared.forcePortraitMode.value diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index 140204358..c46949524 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -14,6 +14,8 @@ struct MainTabView: View { @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition + @ObservedObject private var pendingReviewBolus = Observable.shared.pendingReviewBolus + @State private var showTelemetryConsent = false private var orderedItems: [TabItem] { @@ -60,6 +62,16 @@ struct MainTabView: View { TelemetryConsentView() .interactiveDismissDisabled(true) } + // Presented from the tab root when the user taps a Trio review notification, gated behind the consent + // sheet so they never contend at first launch. + .sheet(isPresented: Binding( + get: { pendingReviewBolus.value != nil && !showTelemetryConsent }, + set: { if !$0 { pendingReviewBolus.value = nil } } + )) { + if let request = pendingReviewBolus.value { + BolusView(reviewRequest: request) + } + } } @ViewBuilder diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 2d6c73a77..f1a32e654 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -3,6 +3,7 @@ import Foundation import UIKit +import WidgetKit extension MainViewController { /// Number of days of BG history to request from the source. One extra day is @@ -322,6 +323,9 @@ extension MainViewController { LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") #endif + // Home screen widget update + self.updateWidgetData(entries) + // Update contact if Storage.shared.contactEnabled.value { self.contactImageUpdater @@ -336,4 +340,61 @@ extension MainViewController { Storage.shared.lastBGChecked.value = Date() } } + + /// Must match the kind the widget registers itself under. + private static let widgetKind = "LoopFollowWidget" + + /// Publishes what the home screen widget draws: its settings, the chart series + /// and the matching snapshot. The redraw is requested only once both files are + /// on disk, so the widget never renders a new chart against the previous + /// reading. Kept off `LiveActivityManager.refreshFromCurrentState`, whose + /// 20 second debounce is starved whenever BG fetches are rescheduled more + /// often than that. + /// - Parameter entries: readings ordered oldest first. + func updateWidgetData(_ entries: [ShareGlucoseData]) { + LAAppGroupSettings.setNightscout(url: Storage.shared.url.value, token: Storage.shared.token.value) + LAAppGroupSettings.setDisplayName( + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", + show: Storage.shared.showDisplayName.value + ) + + // The widget and the Live Activity color their readings by these, so they + // use the same thresholds as the graph and the stats. LiveActivityManager + // publishes the same values on its own paths. + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high) + + // The chart outlives the snapshot, so the unit has to be readable on its + // own rather than only from the reading. + LAAppGroupSettings.setPreferredUnit(PreferredGlucoseUnit.snapshotUnit()) + + // Clamp plotted BG to the display range, as the app's own graph does + // (see #600), so an out-of-range sgv cannot distort the chart scale. + let minDisplay = globalVariables.minDisplayGlucose + let maxDisplay = globalVariables.maxDisplayGlucose + + let cutoff = dateTimeUtils.getNowTimeIntervalUTC() - GlucoseChartSeriesStore.window + let points = entries + .filter { $0.date >= cutoff } + .map { GlucoseChartPoint(value: Double(min(max($0.sgv, minDisplay), maxDisplay)), + date: Date(timeIntervalSince1970: $0.date)) } + + // Without a snapshot there is no reading to draw the chart against, so + // skip the whole publish rather than let the two surfaces diverge. + guard let snapshot = GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()) else { return } + + // A fetch that brings nothing new still lands here, and during a sensor + // gap it lands every few seconds, so reload only on new data. Read both + // stores before saving, or the comparisons are always equal. + let publishedSeriesEnd = GlucoseChartSeriesStore.shared.load()?.points.last?.date + let seriesUnchanged = points.last?.date == publishedSeriesEnd + let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + guard !seriesUnchanged || !snapshotUnchanged else { return } + + GlucoseChartSeriesStore.shared.save(GlucoseChartSeries(points: points, updatedAt: Date())) { + GlucoseSnapshotStore.shared.save(snapshot) { + WidgetCenter.shared.reloadTimelines(ofKind: MainViewController.widgetKind) + } + } + } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index d2d6920d8..e1078e46d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -115,10 +115,12 @@ extension MainViewController { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") Storage.shared.lastPumpReservoirU.value = reservoirData + Storage.shared.lastPumpReservoirAboveMax.value = false } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") Storage.shared.lastPumpReservoirU.value = nil + Storage.shared.lastPumpReservoirAboveMax.value = true } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index d4871f067..ed2c5e9d4 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -251,6 +251,7 @@ class APNSClient { if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery } if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate } if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU } + if snapshot.pumpReservoirAboveMax { snapshotDict["pumpReservoirAboveMax"] = true } if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens } if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd } if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl } diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 5eb1187b8..1c21e62b1 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -54,6 +54,7 @@ enum AppGroupID { ".LiveActivity", ".LiveActivityExtension", ".LoopFollowLAExtension", + ".LoopFollowWidget", ".Widget", ".WidgetExtension", ".Widgets", diff --git a/LoopFollow/LiveActivity/GlucoseChartSeries.swift b/LoopFollow/LiveActivity/GlucoseChartSeries.swift new file mode 100644 index 000000000..c318ed430 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseChartSeries.swift @@ -0,0 +1,133 @@ +// LoopFollow +// GlucoseChartSeries.swift + +import Foundation + +/// A single glucose reading plotted on the home screen widget chart. +struct GlucoseChartPoint: Codable, Equatable, Hashable { + /// Glucose value in mg/dL (canonical internal unit). + let value: Double + + /// Timestamp of the reading. + let date: Date + + init(value: Double, date: Date) { + self.value = value + self.date = date + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value, forKey: .value) + try container.encode(date.timeIntervalSince1970, forKey: .date) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + value = try container.decode(Double.self, forKey: .value) + date = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .date)) + } + + private enum CodingKeys: String, CodingKey { + case value, date + } +} + +/// Recent glucose history for the home screen widget chart. +/// +/// Thresholds and the preferred display unit are read separately from +/// `LAAppGroupSettings`, so this stays a plain series of readings. +struct GlucoseChartSeries: Codable, Equatable { + /// Readings ordered oldest first. + let points: [GlucoseChartPoint] + + /// When the app last wrote this series. + let updatedAt: Date + + /// Age of the series in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } + + init(points: [GlucoseChartPoint], updatedAt: Date) { + self.points = points + self.updatedAt = updatedAt + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(points, forKey: .points) + try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + points = try container.decode([GlucoseChartPoint].self, forKey: .points) + updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) + } + + private enum CodingKeys: String, CodingKey { + case points, updatedAt + } +} + +/// Persists the recent glucose history into the App Group container so the +/// home screen widget can render a chart without the app running. +/// +/// Uses an atomic JSON file write to avoid partial/corrupt reads across processes. +final class GlucoseChartSeriesStore { + static let shared = GlucoseChartSeriesStore() + private init() {} + + /// Readings older than this are dropped when saving. Covers the longest + /// `WidgetChartDuration`, since the widget can only draw what is stored. + static let window: TimeInterval = 24 * 3600 + + private let fileName = "glucose_chart_series.json" + private let queue = DispatchQueue(label: "com.loopfollow.glucoseChartSeriesStore", qos: .utility) + + // MARK: - Public API + + /// The write is asynchronous, so callers that have to act on the stored file + /// (asking the widget to redraw, for one) pass `completion` rather than + /// assuming the series has landed when `save` returns. + func save(_ series: GlucoseChartSeries, completion: (() -> Void)? = nil) { + queue.async { + defer { completion?() } + do { + let url = try self.fileURL() + let data = try JSONEncoder().encode(series) + try data.write(to: url, options: [.atomic]) + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + func load() -> GlucoseChartSeries? { + do { + let url = try fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(GlucoseChartSeries.self, from: data) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + // MARK: - Helpers + + private func fileURL() throws -> URL { + let groupID = AppGroupID.current() + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else { + throw NSError( + domain: "GlucoseChartSeriesStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"], + ) + } + return containerURL.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSlotFormat.swift b/LoopFollow/LiveActivity/GlucoseSlotFormat.swift new file mode 100644 index 000000000..afc41239a --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSlotFormat.swift @@ -0,0 +1,225 @@ +// LoopFollow +// GlucoseSlotFormat.swift + +import Foundation + +/// Renders `GlucoseSnapshot` values as the strings shown in the Live Activity +/// and the home screen widget. Both surfaces format identically, so a value +/// reads the same wherever it appears. +enum LAFormat { + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() + + private static let mmolFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 1 + nf.locale = .current + return nf + }() + + private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" + case .mmol: + let mmol = GlucoseConversion.toMmol(mgdl) + return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) + } + } + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + + static func delta(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + let v = Int(round(s.delta)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(s.delta) + let d = (abs(mmol) < 0.05) ? 0.0 : mmol + if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" } + let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + return d > 0 ? "+\(formatted)" : "-\(formatted)" + } + } + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + switch s.trend { + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" + } + } + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + return String(Int(round(v))) + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secondsAgo = Date().timeIntervalSince1970 - insertTime + return ageFormatter.string(from: secondsAgo) ?? "—" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + /// Omnipod stops short of a number while the reservoir is full and says only + /// that it is over 50U. With no pump record behind it there is no reservoir + /// to report at all, which is not the same thing and must not read as one. + static func pump(_ s: GlucoseSnapshot) -> String { + if let v = s.pumpReservoirU { return "\(Int(round(v)))U" } + return s.pumpReservoirAboveMax ? "50+U" : "\u{2014}" + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func override(_ s: GlucoseSnapshot) -> String { + s.override ?? "—" + } + + static func profileName(_ s: GlucoseSnapshot) -> String { + s.profileName ?? "—" + } + + /// A literal `dateFormat` would pin the clock to 24 hours whatever the device + /// is set to, so the style is left for the locale to resolve. + private static let hhmmFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateStyle = .none + df.timeStyle = .short + return df + }() + + private static let hhmmssFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm:ss" + return df + }() + + static func hhmmss(_ date: Date) -> String { + hhmmssFormatter.string(from: date) + } + + static func updated(_ s: GlucoseSnapshot) -> String { + hhmmFormatter.string(from: s.updatedAt) + } +} + +func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String { + switch option { + case .none: "" + case .delta: LAFormat.delta(snapshot) + case .projectedBG: LAFormat.projected(snapshot) + case .minMax: LAFormat.minMax(snapshot) + case .iob: LAFormat.iob(snapshot) + case .cob: LAFormat.cob(snapshot) + case .recBolus: LAFormat.recBolus(snapshot) + case .autosens: LAFormat.autosens(snapshot) + case .tdd: LAFormat.tdd(snapshot) + case .basal: LAFormat.basal(snapshot) + case .pump: LAFormat.pump(snapshot) + case .pumpBattery: LAFormat.pumpBattery(snapshot) + case .battery: LAFormat.battery(snapshot) + case .target: LAFormat.target(snapshot) + case .isf: LAFormat.isf(snapshot) + case .carbRatio: LAFormat.carbRatio(snapshot) + case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: LAFormat.carbsToday(snapshot) + case .override: LAFormat.override(snapshot) + case .profile: LAFormat.profileName(snapshot) + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index c5f5fffcc..2a8586bc1 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -36,6 +36,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Timestamp of reading. let updatedAt: Date + /// Pump clock of the devicestatus record the metrics below were read from, + /// or nil for a snapshot written before this was recorded. Deliberately not + /// `updatedAt`: the reading and the loop move independently, and a surface + /// that has refreshed one of them needs to know which. + let loopUpdatedAt: Date? + // MARK: - Secondary Metrics /// Insulin On Board @@ -64,9 +70,14 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Formatted current basal rate string (empty if not available) let basalRate: String - /// Pump reservoir in units (nil if >50U or unknown) + /// Pump reservoir in units (nil if unknown, or if above what the pump counts) let pumpReservoirU: Double? + /// True when the pump reported a reservoir it does not put a number on until + /// it drops below 50U, as Omnipod does. Told apart from an unknown reservoir, + /// which is the absence of a pump record rather than a full one. + let pumpReservoirAboveMax: Bool + /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available) let autosens: Double? @@ -130,6 +141,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { delta: Double, trend: Trend, updatedAt: Date, + loopUpdatedAt: Date? = nil, iob: Double?, cob: Double?, projected: Double?, @@ -139,6 +151,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { pumpBattery: Double? = nil, basalRate: String = "", pumpReservoirU: Double? = nil, + pumpReservoirAboveMax: Bool = false, autosens: Double? = nil, tdd: Double? = nil, targetLowMgdl: Double? = nil, @@ -160,6 +173,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.delta = delta self.trend = trend self.updatedAt = updatedAt + self.loopUpdatedAt = loopUpdatedAt self.iob = iob self.cob = cob self.projected = projected @@ -169,6 +183,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.pumpBattery = pumpBattery self.basalRate = basalRate self.pumpReservoirU = pumpReservoirU + self.pumpReservoirAboveMax = pumpReservoirAboveMax self.autosens = autosens self.tdd = tdd self.targetLowMgdl = targetLowMgdl @@ -203,6 +218,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { delta: delta, trend: trend, updatedAt: updatedAt, + loopUpdatedAt: loopUpdatedAt, iob: iob, cob: cob, projected: projected, @@ -212,6 +228,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { pumpBattery: pumpBattery, basalRate: basalRate, pumpReservoirU: pumpReservoirU, + pumpReservoirAboveMax: pumpReservoirAboveMax, autosens: autosens, tdd: tdd, targetLowMgdl: targetLowMgdl, @@ -239,6 +256,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encode(delta, forKey: .delta) try container.encode(trend, forKey: .trend) try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + try container.encodeIfPresent(loopUpdatedAt?.timeIntervalSince1970, forKey: .loopUpdatedAt) try container.encodeIfPresent(iob, forKey: .iob) try container.encodeIfPresent(cob, forKey: .cob) try container.encodeIfPresent(projected, forKey: .projected) @@ -248,6 +266,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) try container.encode(basalRate, forKey: .basalRate) try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) + try container.encode(pumpReservoirAboveMax, forKey: .pumpReservoirAboveMax) try container.encodeIfPresent(autosens, forKey: .autosens) try container.encodeIfPresent(tdd, forKey: .tdd) try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) @@ -272,6 +291,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { delta = try container.decode(Double.self, forKey: .delta) trend = try container.decode(Trend.self, forKey: .trend) updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) + loopUpdatedAt = try container.decodeIfPresent(Double.self, forKey: .loopUpdatedAt).map { Date(timeIntervalSince1970: $0) } iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) @@ -281,6 +301,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) + pumpReservoirAboveMax = try container.decodeIfPresent(Bool.self, forKey: .pumpReservoirAboveMax) ?? false autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) @@ -300,9 +321,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt + case glucose, delta, trend, updatedAt, loopUpdatedAt case iob, cob, projected - case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, pumpReservoirAboveMax case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl case unit, isNotLooping, showRenewalOverlay diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 40ff076af..6263ed16c 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -45,9 +45,13 @@ protocol CurrentGlucoseStateProviding { /// Formatted current basal rate string (empty if not available). var basalRate: String { get } - /// Pump reservoir in units (nil if >50U or unknown). + /// Pump reservoir in units (nil if unknown, or above what the pump counts). var pumpReservoirU: Double? { get } + /// True when the pump reported a reservoir it does not number until it falls + /// below 50U. + var pumpReservoirAboveMax: Bool { get } + /// Autosensitivity ratio, e.g. 0.9 = 90%. var autosens: Double? { get } @@ -92,6 +96,9 @@ protocol CurrentGlucoseStateProviding { /// True when LoopFollow detects the loop has not reported in 15+ minutes. var isNotLooping: Bool { get } + /// Pump clock of the devicestatus record the metrics were read from. + var loopUpdatedAt: Date? { get } + // MARK: - Renewal /// True when the Live Activity is within renewalWarning seconds of its deadline. @@ -137,6 +144,7 @@ enum GlucoseSnapshotBuilder { delta: deltaMgdl, trend: trend, updatedAt: updatedAt, + loopUpdatedAt: provider.loopUpdatedAt, iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, @@ -146,6 +154,7 @@ enum GlucoseSnapshotBuilder { pumpBattery: provider.pumpBattery, basalRate: provider.basalRate, pumpReservoirU: provider.pumpReservoirU, + pumpReservoirAboveMax: provider.pumpReservoirAboveMax, autosens: provider.autosens, tdd: provider.tdd, targetLowMgdl: provider.targetLowMgdl, diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index 7951e122a..4deb4ee81 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -17,8 +17,12 @@ final class GlucoseSnapshotStore { // MARK: - Public API - func save(_ snapshot: GlucoseSnapshot) { + /// The write is asynchronous, so callers that have to act on the stored file + /// (asking the widget to redraw, for one) pass `completion` rather than + /// assuming the snapshot has landed when `save` returns. + func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) { queue.async { + defer { completion?() } do { let url = try self.fileURL() let encoder = JSONEncoder() diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 6359fe55e..feb20c4af 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -133,6 +133,71 @@ enum LiveActivitySlotDefaults { static var all: [LiveActivitySlotOption] { [slot1, slot2, slot3, slot4] } + + /// The home screen widget shows three: the fourth place along its base is + /// the refresh button. + static var widget: [LiveActivitySlotOption] { + [slot1, slot2, slot3] + } +} + +// MARK: - Widget chart duration + +/// Span of glucose history drawn on the home screen widget chart, chosen from +/// the widget's own Edit Widget sheet. +/// +/// `GlucoseChartSeriesStore.window` has to cover the longest case here, or the +/// longer spans would draw the same readings as the shorter ones. +enum WidgetChartDuration: String, CaseIterable, Codable { + case oneHour + case threeHours + case sixHours + case twelveHours + case twentyFourHours + + /// What the widget draws until the user picks something else. The + /// @Parameter default in the configuration intent has to match. + static let standard: WidgetChartDuration = .threeHours + + var seconds: TimeInterval { + switch self { + case .oneHour: 3600 + case .threeHours: 3 * 3600 + case .sixHours: 6 * 3600 + case .twelveHours: 12 * 3600 + case .twentyFourHours: 24 * 3600 + } + } + + var displayName: String { + switch self { + case .oneHour: "1 hour" + case .threeHours: "3 hours" + case .sixHours: "6 hours" + case .twelveHours: "12 hours" + case .twentyFourHours: "24 hours" + } + } +} + +// MARK: - Widget chart style + +/// How the home screen widget chart draws the readings, chosen from the +/// widget's own Edit Widget sheet. +enum WidgetChartStyle: String, CaseIterable, Codable { + case dots + case line + + /// What the widget draws until the user picks something else. The + /// @Parameter default in the configuration intent has to match. + static let standard: WidgetChartStyle = .dots + + var displayName: String { + switch self { + case .dots: "Dots" + case .line: "Line" + } + } } // MARK: - App Group settings @@ -149,6 +214,12 @@ enum LAAppGroupSettings { static let smallWidgetSlot = "la.smallWidgetSlot" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" + static let nightscoutURL = "la.nightscout.url" + static let nightscoutToken = "la.nightscout.token" + static let preferredUnit = "la.preferredUnit" + static let refreshFailedAt = "la.widget.refreshFailedAt" + static let refreshCheckedAt = "la.widget.refreshCheckedAt" + static let refreshBroughtNewData = "la.widget.refreshBroughtNewData" } private static var defaults: UserDefaults? { @@ -220,4 +291,80 @@ enum LAAppGroupSettings { static func showDisplayName() -> Bool { defaults?.bool(forKey: Keys.showDisplayName) ?? false } + + // MARK: - Nightscout connection + + /// Mirrors the site the app polls so an extension can fetch on its own when + /// its cached data has gone stale. An empty url means "not configured". + static func setNightscout(url: String, token: String) { + defaults?.set(url, forKey: Keys.nightscoutURL) + defaults?.set(token, forKey: Keys.nightscoutToken) + } + + static func nightscoutURL() -> String { + defaults?.string(forKey: Keys.nightscoutURL) ?? "" + } + + static func nightscoutToken() -> String { + defaults?.string(forKey: Keys.nightscoutToken) ?? "" + } + + // MARK: - Preferred glucose unit + + /// Mirrors the app's unit selection so a surface that has a chart but no + /// snapshot still labels and scales it in the unit the user reads in. + static func setPreferredUnit(_ unit: GlucoseSnapshot.Unit) { + defaults?.set(unit.rawValue, forKey: Keys.preferredUnit) + } + + static func preferredUnit() -> GlucoseSnapshot.Unit { + guard let raw = defaults?.string(forKey: Keys.preferredUnit) else { return .mgdl } + return GlucoseSnapshot.Unit(rawValue: raw) ?? .mgdl + } + + // MARK: - Widget refresh + + /// When the widget's own refresh last failed to reach Nightscout, so the + /// render that follows can say the tap did not land. Nil clears it, which is + /// what a refresh that did land writes. + static func setRefreshFailed(at date: Date?) { + guard let date else { + defaults?.removeObject(forKey: Keys.refreshFailedAt) + return + } + defaults?.set(date.timeIntervalSince1970, forKey: Keys.refreshFailedAt) + } + + static func refreshFailedAt() -> Date? { + guard let seconds = defaults?.object(forKey: Keys.refreshFailedAt) as? Double, seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + /// When the refresh last reached Nightscout, and whether the site answered + /// with a reading newer than the stored one. A tap that finds nothing newer + /// has still done its work, and the render after it is the only chance to + /// say so: nothing can be drawn while the intent is running. + static func setRefreshChecked(at date: Date, broughtNewData: Bool) { + defaults?.set(date.timeIntervalSince1970, forKey: Keys.refreshCheckedAt) + defaults?.set(broughtNewData, forKey: Keys.refreshBroughtNewData) + } + + static func clearRefreshChecked() { + defaults?.removeObject(forKey: Keys.refreshCheckedAt) + defaults?.removeObject(forKey: Keys.refreshBroughtNewData) + } + + static func refreshCheckedAt() -> Date? { + guard let seconds = defaults?.object(forKey: Keys.refreshCheckedAt) as? Double, seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + static func refreshBroughtNewData() -> Bool { + defaults?.bool(forKey: Keys.refreshBroughtNewData) ?? false + } } + +// Explicit so the widget can use this enum as an AppEnum parameter; the +// implicit conformance would land outside this file. +extension LiveActivitySlotOption: Sendable {} +extension WidgetChartDuration: Sendable {} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index c3b354a82..eaaafbbf5 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -201,10 +201,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + Self.publishThresholds() GlucoseSnapshotStore.shared.save(snapshot) seq += 1 @@ -733,10 +730,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + Self.publishThresholds() LAAppGroupSettings.setDisplayName( Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", show: Storage.shared.showDisplayName.value @@ -1086,6 +1080,15 @@ final class LiveActivityManager { } } + // MARK: - Shared surfaces + + /// Publishes the thresholds the app charts and scores with, so the Live + /// Activity and the home screen widget color a reading the way the app does. + private static func publishThresholds() { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high) + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -1098,27 +1101,25 @@ final class LiveActivityManager { "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) - // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. - if renewIfNeeded(snapshot: snapshot) { return } - - if snapshot.showRenewalOverlay { - LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") - } - let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 // Capture dedup result BEFORE saving so the store comparison is valid. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot - // Store + Watch: always update, independent of LA state. - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + // Store + Watch: always update, independent of LA state, and before any + // LA path can return early. + Self.publishThresholds() GlucoseSnapshotStore.shared.save(snapshot) // WatchConnectivityManager.shared.send(snapshot: snapshot) + // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. + if renewIfNeeded(snapshot: snapshot) { return } + + if snapshot.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") + } + // LA update: gated on LA being active, snapshot having changed, and activities enabled. if !Storage.shared.laEnabled.value { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — laEnabled=false reason=\(reason)", isDebug: true) diff --git a/LoopFollow/LiveActivity/NightscoutChartFetcher.swift b/LoopFollow/LiveActivity/NightscoutChartFetcher.swift new file mode 100644 index 000000000..03009739c --- /dev/null +++ b/LoopFollow/LiveActivity/NightscoutChartFetcher.swift @@ -0,0 +1,146 @@ +// LoopFollow +// NightscoutChartFetcher.swift + +import Foundation + +/// The newest entry of a fetch, carrying the fields a plain list of chart points +/// drops. The value is the reading as posted, not the clamped one the chart +/// plots, so a reading off the end of the scale is still stated as it stands. +struct NightscoutReading { + let mgdl: Double + let date: Date + let deltaMgdl: Double + let direction: String? +} + +/// Fetches recent glucose entries directly from Nightscout for surfaces that run +/// without the app. `NightscoutUtils` is unusable here: it reads `Storage.shared` +/// and logs through `LogManager`, neither of which exists in an extension. +enum NightscoutChartFetcher { + /// Short enough that a timeline reload can afford to wait for it. + static let timeout: TimeInterval = 4 + + /// One reading per five minutes, doubled to leave room for duplicates. + private static let entryCount = Int(GlucoseChartSeriesStore.window / 300) * 2 + + private static let maxPlausibleMgdl: Double = 600 + + /// Display range the app's own graph clamps to (see #600). Sensors report + /// out-of-range and special values, and one of them would otherwise stretch + /// the widget chart until the real readings are unreadable. + private static let minDisplayMgdl: Double = 39 + private static let maxDisplayMgdl: Double = 400 + + // MARK: - Public API + + /// Longer than this between the last two readings and the difference is not + /// a delta: the sensor stopped reporting in between. + private static let maxDeltaGap: TimeInterval = 20 * 60 + + /// The last `GlucoseChartSeriesStore.window` of readings, oldest first, or nil. + static func fetchSeries(baseURL: String, token: String) async -> GlucoseChartSeries? { + await fetch(baseURL: baseURL, token: token)?.series + } + + /// The same readings, plus the head of them as a reading in its own right, so + /// a caller rebuilding a whole snapshot draws the chart and the number it + /// stands beside out of one response rather than two. + /// + /// Nil means the request did not land. A response that lands with nothing + /// plottable in it is the caller's to interpret, not a failure. + static func fetch(baseURL: String, token: String) async -> (series: GlucoseChartSeries, reading: NightscoutReading?)? { + guard let url = entriesURL(baseURL: baseURL, token: token) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + let session = URLSession(configuration: configuration) + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } + let entries = try JSONDecoder().decode([Entry].self, from: data) + guard let series = series(from: entries) else { return nil } + return (series, reading(from: entries)) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + // MARK: - Helpers + + /// Calibrations and meter values come back without an `sgv`. + private struct Entry: Decodable { + let sgv: Double? + let date: Double? + let direction: String? + } + + /// The two newest plausible readings, so the delta is measured across the + /// pair that produced it rather than assumed. A gap wide enough to have + /// swallowed readings leaves the delta at zero, since the difference across + /// it is not the move the arrow describes. + private static func reading(from entries: [Entry]) -> NightscoutReading? { + let recent = entries.compactMap { entry -> (mgdl: Double, date: Date, direction: String?)? in + guard let sgv = entry.sgv, sgv > 0, sgv <= maxPlausibleMgdl, + let milliseconds = entry.date else { return nil } + return (sgv, Date(timeIntervalSince1970: (milliseconds / 1000).rounded()), entry.direction) + } + .sorted { $0.date > $1.date } + + guard let head = recent.first else { return nil } + let previous = recent.first { $0.date < head.date } + var delta: Double = 0 + if let previous, head.date.timeIntervalSince(previous.date) <= maxDeltaGap { + delta = head.mgdl - previous.mgdl + } + return NightscoutReading(mgdl: head.mgdl, date: head.date, deltaMgdl: delta, direction: head.direction) + } + + private static func entriesURL(baseURL: String, token: String) -> URL? { + var components = URLComponents(string: baseURL) + components?.path = "/api/v1/entries.json" + + var queryItems = [URLQueryItem]() + if !token.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: token)) + } + let since = Date().addingTimeInterval(-GlucoseChartSeriesStore.window) + queryItems.append(URLQueryItem(name: "count", value: "\(entryCount)")) + queryItems.append(URLQueryItem(name: "find[date][$gte]", value: "\(Int(since.timeIntervalSince1970 * 1000))")) + queryItems.append(URLQueryItem(name: "find[type][$ne]", value: "cal")) + components?.queryItems = queryItems + + return components?.url + } + + private static func series(from entries: [Entry]) -> GlucoseChartSeries? { + let cutoff = Date().addingTimeInterval(-GlucoseChartSeriesStore.window) + let points = entries.compactMap { entry -> GlucoseChartPoint? in + guard let sgv = entry.sgv, sgv > 0, sgv <= maxPlausibleMgdl, + let milliseconds = entry.date else { return nil } + let date = Date(timeIntervalSince1970: (milliseconds / 1000).rounded()) + guard date >= cutoff else { return nil } + let clamped = min(max(sgv, minDisplayMgdl), maxDisplayMgdl) + return GlucoseChartPoint(value: clamped, date: date) + } + .sorted { $0.date < $1.date } + + // More than one uploader can post the same reading, and the chart + // identifies its points by the whole point, so a repeated timestamp + // would collide. Keep one per timestamp. + var deduped: [GlucoseChartPoint] = [] + for point in points where point.date != deduped.last?.date { + deduped.append(point) + } + + guard !deduped.isEmpty else { return nil } + return GlucoseChartSeries(points: deduped, updatedAt: Date()) + } +} diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 1722d49fe..ca8609a9e 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -67,6 +67,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { Storage.shared.lastPumpReservoirU.value } + var pumpReservoirAboveMax: Bool { + Storage.shared.lastPumpReservoirAboveMax.value + } + var autosens: Double? { Storage.shared.lastAutosens.value } @@ -128,6 +132,12 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60 } + var loopUpdatedAt: Date? { + let lastLoopTime = Storage.shared.lastLoopTime.value + guard lastLoopTime > 0 else { return nil } + return Date(timeIntervalSince1970: lastLoopTime) + } + // MARK: - Renewal var showRenewalOverlay: Bool { diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 7a59c87de..14b3fd25d 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -25,6 +25,26 @@ struct BolusView: View { private let pushNotificationManager = PushNotificationManager() + /// Trio's recommended bolus awaiting review, when this screen was opened from a review notification. + private let reviewRequest: ReviewBolusRequest? + + // Review-recommendation staleness, mirroring the deviceRecBolus age semantics: warn at 5 min, expire at 12. + private static let reviewWarnAge: TimeInterval = 5 * 60 + private static let reviewExpiredAge: TimeInterval = 12 * 60 + + private static func reviewAge(sentAt: TimeInterval) -> TimeInterval { + max(0, Date().timeIntervalSince1970 - sentAt) + } + + init(reviewRequest: ReviewBolusRequest? = nil) { + self.reviewRequest = reviewRequest + if let reviewRequest, Self.reviewAge(sentAt: reviewRequest.sentAt) < Self.reviewExpiredAge { + let maxU = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + let clamped = max(0, min(reviewRequest.amount, maxU)) + _bolusAmount = State(initialValue: HKQuantity(unit: .internationalUnit(), doubleValue: clamped)) + } + } + enum AlertType { case confirmBolus case statusSuccess @@ -68,6 +88,8 @@ struct BolusView: View { Form { recommendedBlocks(now: context.date) + reviewStalenessBlock(now: context.date) + if !quickPickBoluses.quickPickBoluses.isEmpty { Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { ScrollView(.horizontal, showsIndicators: false) { @@ -148,6 +170,11 @@ struct BolusView: View { stepIncrement: stepU, maxBolus: maxBolus.value.doubleValue(for: .internationalUnit()) ) + + // Drop the delivered notification for an expired recommendation so it can't be re-tapped. + if let reviewRequest, Self.reviewAge(sentAt: reviewRequest.sentAt) >= Self.reviewExpiredAge { + AppDelegate.removeDeliveredRecommendedBolusNotifications() + } } .alert(isPresented: $showAlert) { switch alertType { @@ -275,6 +302,30 @@ struct BolusView: View { } } + /// Age warning for a review recommendation, mirroring the deviceRecBolus warning styling. + @ViewBuilder + private func reviewStalenessBlock(now: Date) -> some View { + if let reviewRequest { + let ageSec = max(0, now.timeIntervalSince1970 - reviewRequest.sentAt) + + if ageSec >= Self.reviewExpiredAge { + Section { + Text("This recommended bolus expired (calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago). Re-send the meal from your app to get a fresh recommendation.") + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } + } else if ageSec >= Self.reviewWarnAge { + Section { + Text("WARNING: This recommended bolus was calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago. New treatments may have occurred since then; review before sending.") + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } + } + } + } + private func handleRecommendedBolusTap(rec: Double, ageSec: TimeInterval) { let isStale5 = ageSec >= 5 * 60 let isStale12 = ageSec >= 12 * 60 @@ -322,6 +373,10 @@ struct BolusView: View { if sentUnits > 0 { QuickPickBolusesManager.shared.recordBolus(units: sentUnits) } + // Drop the delivered notification once acted on so it can't be confirmed again. + if reviewRequest != nil { + AppDelegate.removeDeliveredRecommendedBolusNotifications() + } statusMessage = "Bolus command sent successfully." LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 5938735d1..4c0d66c67 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -11,6 +11,7 @@ struct MealView: View { @State private var protein = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var fat = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + @State private var useRecommendedBolus: Bool = false private let pushNotificationManager = PushNotificationManager() @@ -142,23 +143,44 @@ struct MealView: View { } if mealWithBolus.value { - HKQuantityInputView( - label: "Bolus Amount", - quantity: $bolusAmount, - unit: .internationalUnit(), - maxLength: 4, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0), - maxValue: maxBolus.value, - isFocused: $bolusFieldIsFocused, - onValidationError: { message in - handleValidationError(message) + Toggle("Use Trio's recommended bolus", isOn: $useRecommendedBolus) + .disabled(isScheduling) + .onChange(of: useRecommendedBolus) { _ in + if useRecommendedBolus { + bolusFieldIsFocused = false + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + } } - ) + + if useRecommendedBolus { + Text("Trio will calculate the dose from its current glucose, IOB, and COB using its own bolus calculator and safety limits. The patient's Trio Remote Meal Bolus setting decides whether Trio doses it automatically or sends it back here for you to review and confirm. This requires that setting to be enabled (Require Review or Auto) on the patient's Trio app.") + .font(.footnote) + .foregroundColor(.secondary) + } else { + HKQuantityInputView( + label: "Bolus Amount", + quantity: $bolusAmount, + unit: .internationalUnit(), + maxLength: 4, + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + maxValue: maxBolus.value, + isFocused: $bolusFieldIsFocused, + onValidationError: { message in + handleValidationError(message) + } + ) + } } } Section(header: Text("Schedule")) { Toggle("Schedule for later", isOn: $isScheduling) + .onChange(of: isScheduling) { _ in + // Trio only auto-boluses a meal timed for now, so clear the recommendation while scheduling. + if isScheduling { + useRecommendedBolus = false + } + } if isScheduling { DatePicker( "Select Time", @@ -220,6 +242,7 @@ struct MealView: View { .onAppear { selectedTime = nil isScheduling = false + useRecommendedBolus = false quickPickMeals.refresh( maxCarbs: maxCarbs.value.doubleValue(for: .gram()), @@ -265,12 +288,16 @@ struct MealView: View { message += String(format: "\nBolus: %.2f U", bolusAmount) } + if useRecommendedBolus { + message += "\nBolus: Trio's recommended amount" + } + return Alert( title: Text("Confirm Meal"), message: Text(message), primaryButton: .default(Text("Confirm"), action: { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if bolusAmount > 0 { + if bolusAmount > 0 || useRecommendedBolus { AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in DispatchQueue.main.async { switch result { @@ -348,7 +375,8 @@ struct MealView: View { protein: protein, fat: fat, bolusAmount: bolusAmount, - scheduledTime: scheduledDate + scheduledTime: scheduledDate, + useRecommendedBolus: useRecommendedBolus ) { success, errorMessage in DispatchQueue.main.async { isLoading = false @@ -372,6 +400,8 @@ struct MealView: View { carbs = HKQuantity(unit: .gram(), doubleValue: 0.0) protein = HKQuantity(unit: .gram(), doubleValue: 0.0) fat = HKQuantity(unit: .gram(), doubleValue: 0.0) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + useRecommendedBolus = false selectedTime = nil isScheduling = false alertType = .statusSuccess diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/LoopFollow/Remote/TRC/PushMessage.swift index 09ea4e817..3e2c2d71e 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/LoopFollow/Remote/TRC/PushMessage.swift @@ -43,6 +43,7 @@ struct CommandPayload: Encodable { var fat: Int? var overrideName: String? var scheduledTime: TimeInterval? + var useRecommendedBolus: Bool? var returnNotification: ReturnNotificationInfo? struct ReturnNotificationInfo: Encodable { @@ -75,6 +76,7 @@ struct CommandPayload: Encodable { case fat case overrideName case scheduledTime = "scheduled_time" + case useRecommendedBolus = "use_recommended_bolus" case returnNotification = "return_notification" } } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index aa1f661a2..e4ed334a6 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -130,6 +130,7 @@ class PushNotificationManager { fat: HKQuantity, bolusAmount: HKQuantity, scheduledTime: Date?, + useRecommendedBolus: Bool, completion: @escaping (Bool, String?) -> Void ) { func convertToOptionalInt(_ quantity: HKQuantity) -> Int? { @@ -159,6 +160,7 @@ class PushNotificationManager { protein: proteinValue, fat: fatValue, scheduledTime: scheduledTimeInterval, + useRecommendedBolus: useRecommendedBolus ? true : nil, returnNotification: createReturnNotificationInfo() ) sendEncryptedCommand(payload: payload, completion: completion) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 4128918db..1b1f8afb9 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -53,5 +53,18 @@ class Observable { /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs var selectedTabIndex = ObservableValue(default: 0) + /// Set when the user taps a Trio "recommended bolus" review notification. Drives a pre-filled bolus + /// sheet; cleared when the sheet is dismissed. + var pendingReviewBolus = ObservableValue(default: nil) + private init() {} } + +/// A recommended bolus from Trio awaiting the caregiver's review, carried from the notification tap to the +/// pre-filled bolus sheet. +struct ReviewBolusRequest: Equatable { + /// Recommended amount in units of insulin. + let amount: Double + /// The notification's send time (seconds since 1970), used to gate on staleness. + let sentAt: TimeInterval +} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4dd7370a8..4a2d4725e 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -100,6 +100,7 @@ class Storage { // Live Activity extended InfoType data var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastPumpReservoirAboveMax = StorageValue(key: "lastPumpReservoirAboveMax", defaultValue: false) var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) @@ -335,6 +336,7 @@ class Storage { lastBasal.reload() lastPumpReservoirU.reload() + lastPumpReservoirAboveMax.reload() lastAutosens.reload() lastTdd.reload() lastTargetLowMgdl.reload() diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index f854e6615..8410d2661 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -359,33 +359,6 @@ private struct MetricBlock: View { } } -private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String { - switch option { - case .none: "" - case .delta: LAFormat.delta(snapshot) - case .projectedBG: LAFormat.projected(snapshot) - case .minMax: LAFormat.minMax(snapshot) - case .iob: LAFormat.iob(snapshot) - case .cob: LAFormat.cob(snapshot) - case .recBolus: LAFormat.recBolus(snapshot) - case .autosens: LAFormat.autosens(snapshot) - case .tdd: LAFormat.tdd(snapshot) - case .basal: LAFormat.basal(snapshot) - case .pump: LAFormat.pump(snapshot) - case .pumpBattery: LAFormat.pumpBattery(snapshot) - case .battery: LAFormat.battery(snapshot) - case .target: LAFormat.target(snapshot) - case .isf: LAFormat.isf(snapshot) - case .carbRatio: LAFormat.carbRatio(snapshot) - case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) - case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) - case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) - case .carbsToday: LAFormat.carbsToday(snapshot) - case .override: LAFormat.override(snapshot) - case .profile: LAFormat.profileName(snapshot) - } -} - private struct SlotView: View { let option: LiveActivitySlotOption let snapshot: GlucoseSnapshot @@ -530,193 +503,6 @@ private struct DynamicIslandMinimalView: View { } } -// MARK: - Formatting - -private enum LAFormat { - private static let mgdlFormatter: NumberFormatter = { - let nf = NumberFormatter() - nf.numberStyle = .decimal - nf.maximumFractionDigits = 0 - nf.locale = .current - return nf - }() - - private static let mmolFormatter: NumberFormatter = { - let nf = NumberFormatter() - nf.numberStyle = .decimal - nf.minimumFractionDigits = 1 - nf.maximumFractionDigits = 1 - nf.locale = .current - return nf - }() - - private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { - switch unit { - case .mgdl: - return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" - case .mmol: - let mmol = GlucoseConversion.toMmol(mgdl) - return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) - } - } - - static func glucose(_ s: GlucoseSnapshot) -> String { - formatGlucoseValue(s.glucose, unit: s.unit) - } - - static func delta(_ s: GlucoseSnapshot) -> String { - switch s.unit { - case .mgdl: - let v = Int(round(s.delta)) - if v == 0 { return "0" } - return v > 0 ? "+\(v)" : "\(v)" - case .mmol: - let mmol = GlucoseConversion.toMmol(s.delta) - let d = (abs(mmol) < 0.05) ? 0.0 : mmol - if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" } - let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) - return d > 0 ? "+\(formatted)" : "-\(formatted)" - } - } - - static func trendArrow(_ s: GlucoseSnapshot) -> String { - switch s.trend { - case .upFast: "↑↑" - case .up: "↑" - case .upSlight: "↗" - case .flat: "→" - case .downSlight: "↘︎" - case .down: "↓" - case .downFast: "↓↓" - case .unknown: "–" - } - } - - static func iob(_ s: GlucoseSnapshot) -> String { - guard let v = s.iob else { return "—" } - return String(format: "%.1f", v) - } - - static func cob(_ s: GlucoseSnapshot) -> String { - guard let v = s.cob else { return "—" } - return String(Int(round(v))) - } - - static func projected(_ s: GlucoseSnapshot) -> String { - guard let v = s.projected else { return "—" } - return formatGlucoseValue(v, unit: s.unit) - } - - private static let ageFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.unitsStyle = .positional - f.allowedUnits = [.day, .hour] - f.zeroFormattingBehavior = [.pad] - return f - }() - - static func age(insertTime: TimeInterval) -> String { - guard insertTime > 0 else { return "—" } - let secondsAgo = Date().timeIntervalSince1970 - insertTime - return ageFormatter.string(from: secondsAgo) ?? "—" - } - - static func recBolus(_ s: GlucoseSnapshot) -> String { - guard let v = s.recBolus else { return "—" } - return String(format: "%.2fU", v) - } - - static func autosens(_ s: GlucoseSnapshot) -> String { - guard let v = s.autosens else { return "—" } - return String(format: "%.0f%%", v * 100) - } - - static func tdd(_ s: GlucoseSnapshot) -> String { - guard let v = s.tdd else { return "—" } - return String(format: "%.1fU", v) - } - - static func basal(_ s: GlucoseSnapshot) -> String { - s.basalRate.isEmpty ? "—" : s.basalRate - } - - static func pump(_ s: GlucoseSnapshot) -> String { - guard let v = s.pumpReservoirU else { return "50+U" } - return "\(Int(round(v)))U" - } - - static func pumpBattery(_ s: GlucoseSnapshot) -> String { - guard let v = s.pumpBattery else { return "—" } - return String(format: "%.0f%%", v) - } - - static func battery(_ s: GlucoseSnapshot) -> String { - guard let v = s.battery else { return "—" } - return String(format: "%.0f%%", v) - } - - static func target(_ s: GlucoseSnapshot) -> String { - guard let low = s.targetLowMgdl, low > 0 else { return "—" } - let lowStr = formatGlucoseValue(low, unit: s.unit) - if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { - return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" - } - return lowStr - } - - static func isf(_ s: GlucoseSnapshot) -> String { - guard let v = s.isfMgdlPerU, v > 0 else { return "—" } - return formatGlucoseValue(v, unit: s.unit) - } - - static func carbRatio(_ s: GlucoseSnapshot) -> String { - guard let v = s.carbRatio, v > 0 else { return "—" } - return String(format: "%.0fg", v) - } - - static func carbsToday(_ s: GlucoseSnapshot) -> String { - guard let v = s.carbsToday else { return "—" } - return "\(Int(round(v)))g" - } - - static func minMax(_ s: GlucoseSnapshot) -> String { - guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } - return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" - } - - static func override(_ s: GlucoseSnapshot) -> String { - s.override ?? "—" - } - - static func profileName(_ s: GlucoseSnapshot) -> String { - s.profileName ?? "—" - } - - private static let hhmmFormatter: DateFormatter = { - let df = DateFormatter() - df.locale = .current - df.timeZone = .current - df.dateFormat = "HH:mm" - return df - }() - - private static let hhmmssFormatter: DateFormatter = { - let df = DateFormatter() - df.locale = .current - df.timeZone = .current - df.dateFormat = "HH:mm:ss" - return df - }() - - static func hhmmss(_ date: Date) -> String { - hhmmssFormatter.string(from: date) - } - - static func updated(_ s: GlucoseSnapshot) -> String { - hhmmFormatter.string(from: s.updatedAt) - } -} - // MARK: - Threshold-driven colors private enum LAColors { diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift new file mode 100644 index 000000000..29271d3a7 --- /dev/null +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -0,0 +1,147 @@ +// LoopFollow +// GlucoseWidgetEntry.swift + +import Foundation +import WidgetKit + +/// What a refresh that reached Nightscout has to say for itself. A widget cannot +/// redraw while its intent is running, so nothing can be shown mid fetch and the +/// render that follows carries the whole of the feedback a tap gets. +enum WidgetRefreshConfirmation { + /// A newer reading was written. The age line resets itself, so this only has + /// to acknowledge the tap. + case updated + + /// The reading stood, but the loop had moved on since the stored snapshot + /// was written, so the metrics and the loop's own state were replaced. Held + /// apart from `updated` because the age line did not reset: what is on screen + /// is the reading that was already there, at the age it already had. + case loopUpdated + + /// The site had nothing newer, which is the answer rather than a dead press. + /// It says the data was checked, never that the reading is any younger. + case upToDate +} + +/// What the refresh button is drawing. The three answering states are held for a +/// few seconds only: the button has to be recognisable as a control again long +/// before anyone reaches for it a second time, so the wording under the age is +/// what carries the answer afterwards. +enum WidgetRefreshButtonPhase { + case idle + case justUpdated + case justChecked + case justFailed + case failed +} + +/// One rendered state of the home screen widget. +struct GlucoseWidgetEntry: TimelineEntry { + let date: Date + let series: GlucoseChartSeries? + let snapshot: GlucoseSnapshot? + let slots: [LiveActivitySlotOption] + + /// Span of history the chart draws, chosen in Edit Widget. + var duration: WidgetChartDuration = .standard + + /// How the readings are drawn, chosen in Edit Widget. + var chartStyle: WidgetChartStyle = .standard + + /// Whether there is a Nightscout site for the refresh to ask. Without one, + /// as for a Dexcom-only setup, the button has nothing to fetch and is left + /// out rather than sitting there doing nothing. + var canRefresh: Bool = false + + /// When the refresh button last failed to reach Nightscout, as the widget + /// extension recorded it. Nil once a refresh has landed. + var refreshFailedAt: Date? + + /// When the refresh last reached Nightscout, whether it brought back a newer + /// reading than the one already stored, and failing that whether it found the + /// loop somewhere other than where the snapshot had it. + var refreshCheckedAt: Date? + var refreshBroughtNewData: Bool = false + var refreshMovedLoop: Bool = false + + /// Clock disagreement small enough to be ordinary drift between the phone + /// and whatever uploaded the reading. + private static let clockSkewTolerance: TimeInterval = 60 + + /// How long the button keeps saying a tap did not land. The timeline is a + /// run of entries at advancing dates, so this is what clears the mark + /// without a reload: the later entries simply fall outside it. + private static let refreshFailureWindow: TimeInterval = 5 * 60 + + /// How long the wording that acknowledges the tap stays up. Short, because + /// it is an answer to a press and not a state; the provider puts an entry at + /// the end of it so it clears on screen rather than at the next reload. + /// + /// Nothing in it counts, either. How long ago the check was is a fact the + /// reading's own age already covers, and spelling it out a second time grew + /// a line across the chart that was at its widest once it mattered least. + static let confirmationWindow: TimeInterval = 30 + + /// How long the button itself leaves its idle glyph to acknowledge the tap. + /// Long enough that a glance away and back still catches it, short enough + /// that the control is recognisably a refresh again well before anyone would + /// press it a second time. The wording below the age outlasts it by design, + /// so the answer is never gone with the glyph. + static let buttonFlashWindow: TimeInterval = 4 + + /// Age of the reading and metrics, or nil when there is no snapshot. The + /// Nightscout fallback renews only the series, so anything drawn from the + /// snapshot must be judged by this, never by the chart. + var snapshotAge: TimeInterval? { + guard let updatedAt = snapshot?.updatedAt else { return nil } + return max(0, date.timeIntervalSince(updatedAt)) + } + + /// A reading stamped far enough ahead of us that its age cannot be told at + /// all: the uploader's clock is wrong, so the reading may be any age. Taken + /// from the raw difference, since `snapshotAge` floors at zero and would + /// report a skewed reading as brand new. + var isTimestampAhead: Bool { + guard let updatedAt = snapshot?.updatedAt else { return false } + return updatedAt.timeIntervalSince(date) > Self.clockSkewTolerance + } + + /// Whether this render should still be reporting a refresh that did not land. + var refreshDidFail: Bool { + guard let refreshFailedAt else { return false } + let since = date.timeIntervalSince(refreshFailedAt) + return since >= 0 && since < Self.refreshFailureWindow + } + + /// What this render should be saying about the last refresh that landed. + /// Expires against the entry's own date, so a run of entries at advancing + /// dates drops it without a reload. + var refreshConfirmation: WidgetRefreshConfirmation? { + guard !refreshDidFail, let refreshCheckedAt else { return nil } + let since = date.timeIntervalSince(refreshCheckedAt) + guard since >= 0, since < Self.confirmationWindow else { return nil } + if refreshBroughtNewData { return .updated } + return refreshMovedLoop ? .loopUpdated : .upToDate + } + + /// Whether the answer to the last tap is new enough that the button is still + /// acknowledging it rather than sitting at its idle glyph. + var isFlashing: Bool { + let mark = refreshDidFail ? refreshFailedAt : refreshCheckedAt + guard let mark else { return false } + let since = date.timeIntervalSince(mark) + return since >= 0 && since < Self.buttonFlashWindow + } + + /// What the button draws. The failure state outlives its flash because a tap + /// that did not land is a standing condition rather than an acknowledgement. + var refreshPhase: WidgetRefreshButtonPhase { + if refreshDidFail { return isFlashing ? .justFailed : .failed } + guard isFlashing, let refreshConfirmation else { return .idle } + switch refreshConfirmation { + // Green is the reading's, and the reading did not move for the other two. + case .updated: return .justUpdated + case .loopUpdated, .upToDate: return .justChecked + } + } +} diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift new file mode 100644 index 000000000..b3a3f1ce2 --- /dev/null +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -0,0 +1,423 @@ +// LoopFollow +// LoopFollowWidget.swift + +import AppIntents +import SwiftUI +import WidgetKit + +/// Medium home screen widget: the configured span of glucose as a full bleed +/// backdrop, the current reading floating over it, and along the base three +/// configurable metrics with the refresh button in the fourth place. +struct LoopFollowWidgetView: View { + let entry: GlucoseWidgetEntry + + /// The tinted and clear home screen appearances flatten every colour to one + /// tint, so those layouts have to separate by opacity rather than by hue. + @Environment(\.widgetRenderingMode) private var renderingMode + + /// Past this age the reading is no longer presented as the current value. + private static let staleThreshold: TimeInterval = 15 * 60 + + private static let inset: CGFloat = 15 + + /// The series outlives the snapshot, so fall back to what the app last + /// published rather than to mg/dL, which would relabel an mmol/L chart. + private var unit: GlucoseSnapshot.Unit { + entry.snapshot?.unit ?? LAAppGroupSettings.preferredUnit() + } + + private var thresholds: (low: Double, high: Double) { + LAAppGroupSettings.thresholdsMgdl() + } + + private var isFullColor: Bool { + renderingMode == .fullColor + } + + /// Height the metric row claims along the base, and the strip at the top the + /// reading needs. The chart keeps its plot content out of both, so nothing it + /// draws, the threshold lines above all, ends up underneath the text. + private static let metricBandHeight: CGFloat = 50 + private static let readingHeadroom: CGFloat = 16 + + /// Metric blocks along the base. The fourth place is the refresh button. + private static let slotCount = 3 + + /// Wide enough to take a thumb without the blocks beside it losing room. + private static let refreshDiameter: CGFloat = 36 + + /// Missing data is treated as stale: never show a number without an age. So + /// is a reading from the future, whose real age is unknown rather than zero. + private var isStale: Bool { + guard let age = entry.snapshotAge, !entry.isTimestampAhead else { return true } + return age >= Self.staleThreshold + } + + private func color(forMgdl mgdl: Double, thresholds t: (low: Double, high: Double)) -> Color { + if mgdl < t.low { + return Color(.systemRed) + } else if mgdl > t.high { + return Color(.systemOrange) + } else { + return Color(.systemGreen) + } + } + + var body: some View { + ZStack(alignment: .topLeading) { + chart + .frame(maxWidth: .infinity, maxHeight: .infinity) + .mask(legibilityMask) + + reading(thresholds: thresholds) + .padding(.leading, Self.inset) + .padding(.top, 10) + + metricBand + } + .containerBackground(.fill.tertiary, for: .widget) + } + + // MARK: - Legibility + + /// The chart is the whole widget, so the reading and the metrics sit on top of + /// it. Rather than laying a panel over the plot, the plot is held back + /// underneath them: a dense run of points keeps its shape at a fraction of the + /// contrast, and what shows through is the widget's own background, which the + /// tinted and clear appearances are free to replace. + /// + /// Every ramp reaches its end value inside the widget, so no edge is drawn. + private var legibilityMask: some View { + Rectangle() + .fill(.black) + .overlay { + // Union of the two quiet regions. Overlapping soft fields compose + // to a soft field, so the seam between them is not a contour. + ZStack { + Rectangle() + .fill(.black) + .mask(Self.ramp(.leading, .trailing, hold: 0.36, fade: 0.66)) + .mask(Self.ramp(.top, .bottom, hold: 0.5, fade: 0.88)) + + Rectangle() + .fill(.black) + .mask(Self.ramp(.bottom, .top, hold: 0.22, fade: 0.46)) + } + .compositingGroup() + .opacity(0.86) + .blendMode(.destinationOut) + } + .compositingGroup() + } + + private static func ramp(_ from: UnitPoint, _ to: UnitPoint, hold: Double, fade: Double) -> LinearGradient { + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: hold), + .init(color: .clear, location: fade), + ], + startPoint: from, + endPoint: to + ) + } + + /// A halo in the widget's background colour, so the reading survives where the + /// corner fade has run out. Tinted appearances would flatten it into a glow. + @ViewBuilder + private func halo(_ content: some View) -> some View { + if isFullColor, entry.series != nil { + content + .shadow(color: Color(.systemBackground).opacity(0.9), radius: 1.5) + .shadow(color: Color(.systemBackground).opacity(0.45), radius: 5) + } else { + content + } + } + + @ViewBuilder + private func reading(thresholds t: (low: Double, high: Double)) -> some View { + if let snapshot = entry.snapshot { + halo( + VStack(alignment: .leading, spacing: 1) { + HStack(alignment: .firstTextBaseline, spacing: 1) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 40, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(isStale ? AnyShapeStyle(.secondary) : AnyShapeStyle(color(forMgdl: snapshot.glucose, thresholds: t))) + .minimumScaleFactor(0.6) + + if !isStale { + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 21, weight: .semibold, design: .rounded)) + .foregroundStyle(color(forMgdl: snapshot.glucose, thresholds: t)) + } + } + .widgetAccentable() + + // The delta holds its place whatever the age: a stale reading + // still moved, and that direction is worth keeping on screen. + Text("\(LAFormat.delta(snapshot)) \(unit.displayName)") + .font(.system(size: 13.5, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.secondary) + + age(of: snapshot) + + statusLine(snapshot) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + ) + } else { + halo( + VStack(alignment: .leading, spacing: 3) { + warning("No glucose", color: Color(.systemOrange), size: 16) + Text("Open Loop Follow") + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + ) + } + } + + /// How long ago the reading was taken, as a clock the system advances itself. + /// A date styled `Text` is re-rendered on screen without spending a timeline + /// reload, so the age stays true through exactly the stretches where WidgetKit + /// is refusing to refresh us and an old number is most dangerous. + /// + /// The relative style is unsigned, so it would count up from a timestamp in + /// the future and read as fresh. That case is caught before it is drawn and + /// says so plainly instead, since an age cannot be told from a wrong clock. + /// + /// Anchored to the reading, not to the entry that happens to be on screen. + private func age(of snapshot: GlucoseSnapshot) -> some View { + HStack(spacing: 3) { + if isStale { + Image(systemName: "exclamationmark.triangle.fill") + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: 10.5)) + } + Group { + if entry.isTimestampAhead { + Text("clock ahead") + } else { + Text(snapshot.updatedAt, style: .relative) + Text(" ago") + } + } + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .monospacedDigit() + } + .foregroundStyle(isStale ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) + } + + /// The one line under the age. A loop that has stopped leads it, and the + /// answer to a tap sits beside that rather than waiting for the line to come + /// free: a stopped loop is exactly when the button gets pressed hardest, and + /// a press that teaches nothing is the reason to press again. + /// + /// The refresh half never borrows the warning triangle. That glyph means the + /// reading or the loop is in trouble, and a refresh that failed to reach the + /// site says so in its own word instead. + @ViewBuilder + private func statusLine(_ snapshot: GlucoseSnapshot) -> some View { + HStack(spacing: 7) { + if snapshot.isNotLooping { + warning("Not Looping", color: Color(.systemRed)) + } + + if entry.refreshDidFail { + let orange = AnyShapeStyle(Color(.systemOrange)) + refreshNote("exclamationmark", "Refresh failed", settled: orange, arriving: orange) + } else if let confirmation = entry.refreshConfirmation { + refreshNote("checkmark", word(for: confirmation), settled: AnyShapeStyle(.secondary), arriving: AnyShapeStyle(.primary)) + } + } + } + + /// The answer to a tap, drawn under the age because that is the line it is + /// most likely to be misread as correcting. It is about the check, never + /// about the reading: the age above it is left exactly as it was, still + /// counting, and where that age is a stale one this says outright that + /// nothing newer exists so the warning above keeps the room. + /// + /// Short, and no clock of its own. It lies over the chart for the half minute + /// it is up, which a fixed word can afford and a running count cannot. It + /// arrives at full strength and settles to grey once the button is done + /// acknowledging the tap, so the change is caught without the line going on + /// competing with the reading. + private func refreshNote(_ symbol: String, _ text: String, settled: AnyShapeStyle, arriving: AnyShapeStyle) -> some View { + HStack(spacing: 3) { + Image(systemName: symbol) + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: 9, weight: .semibold)) + Text(text) + } + .font(.system(size: 11, weight: entry.isFlashing ? .bold : .medium, design: .rounded)) + .foregroundStyle(entry.isFlashing ? arriving : settled) + } + + /// Only `updated` gets to imply the number above it moved. A refresh that + /// found the loop somewhere new says so in those terms and leaves the reading + /// out of it, and once the reading is stale that is the fact worth the line: + /// the loop half of the answer is already on screen beside this, in the + /// warning appearing or going. + private func word(for state: WidgetRefreshConfirmation) -> String { + switch state { + case .updated: return "Updated" + case .loopUpdated: return isStale ? "No newer reading" : "Loop status updated" + case .upToDate: return isStale ? "No newer reading" : "Up to date" + } + } + + private func warning(_ text: String, color: Color, size: CGFloat = 11.5) -> some View { + HStack(spacing: 3) { + Image(systemName: "exclamationmark.triangle.fill") + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: size - 1.5)) + Text(text) + .font(.system(size: size, weight: .semibold, design: .rounded)) + } + .foregroundStyle(color) + } + + // MARK: - Metrics + + // Aligned by bottom edge: the button is a fixed circle and the blocks are + // text of whatever height their labels need, so this is what puts them on + // one line along the base. + private var metricBand: some View { + HStack(alignment: .bottom, spacing: 8) { + ForEach(Array(entry.slots.prefix(Self.slotCount).enumerated()), id: \.offset) { _, option in + WidgetSlotView(option: option, snapshot: entry.snapshot, isStale: isStale) + } + refreshButton + } + .padding(.horizontal, Self.inset) + .padding(.bottom, 13) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + + /// Bottom right, where a thumb reaches it. It answers a tap by changing its + /// glyph for a few seconds and then going back to the arrow, so the press + /// visibly lands without the control ending up permanently dressed as + /// something that has already been used. The circle and its border never + /// change, which is what keeps it reading as pressable throughout. + /// + /// It is never the only answer. Everything it shows is said in words under + /// the age as well, and those words outlast it. + /// + /// There is no state for the fetch itself. A marker written before the + /// network call plus a `reloadTimelines` does not produce one: measured with + /// the intent held open twelve seconds, the provider was not asked for a + /// timeline once in that window, then asked 138ms after `perform` returned. + /// The result is the first thing that can be drawn, so the job here is to + /// make it impossible to miss when it lands. + /// + /// What it refreshes is the reading, the chart, and whatever the loop posts + /// to devicestatus. The blocks it cannot source are rebuilt empty, so a + /// metric never survives a refresh with an age it no longer has. + @ViewBuilder + private var refreshButton: some View { + if entry.canRefresh { + Button(intent: RefreshWidgetIntent()) { + Image(systemName: Self.symbol(for: entry.refreshPhase)) + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(tint(for: entry.refreshPhase)) + .frame(width: Self.refreshDiameter, height: Self.refreshDiameter) + .background( + // The plot runs underneath, so the glyph needs its own + // ground. The widget's background colour is what the mask + // already lets through elsewhere, which keeps the tinted + // and clear appearances free to substitute their own. + Circle().fill(isFullColor ? AnyShapeStyle(Color(.systemBackground).opacity(0.55)) : AnyShapeStyle(.tertiary)) + ) + .overlay( + Circle().strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(refreshLabel) + } + } + + /// No glyph here implies motion: nothing animates between two timeline + /// entries, so a part turned arrow would claim a spin that never happens. + private static func symbol(for phase: WidgetRefreshButtonPhase) -> String { + switch phase { + case .idle, .failed: return "arrow.clockwise" + case .justUpdated, .justChecked: return "checkmark" + case .justFailed: return "exclamationmark" + } + } + + private func tint(for phase: WidgetRefreshButtonPhase) -> AnyShapeStyle { + switch phase { + case .idle, .justChecked: return AnyShapeStyle(.secondary) + case .justUpdated: return AnyShapeStyle(Color(.systemGreen)) + case .justFailed, .failed: return AnyShapeStyle(Color(.systemOrange)) + } + } + + private var refreshLabel: String { + if entry.refreshDidFail { return "Refresh failed, try again" } + switch entry.refreshConfirmation { + case .updated: return "Refreshed" + case .loopUpdated: return isStale ? "Refreshed, no newer reading" : "Refreshed, loop status updated" + case .upToDate: return isStale ? "Refreshed, no newer reading" : "Refreshed, up to date" + case .none: return "Refresh" + } + } + + // MARK: - Chart + + @ViewBuilder + private var chart: some View { + if let series = entry.series { + WidgetChartView( + series: series, + unit: unit, + duration: entry.duration, + style: entry.chartStyle, + bottomReserve: Self.metricBandHeight, + topReserve: Self.readingHeadroom + ) + } else if entry.snapshot != nil { + // Only worth saying when a reading is on screen without a chart to put + // it in. With nothing at all, the reading block already says so. + // Centred in what the floating reading leaves free, not in the widget. + Text("No recent glucose") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, 110) + } + } +} + +struct LoopFollowWidget: Widget { + private let kind = "LoopFollowWidget" + + /// Keeps side-by-side builds apart in the widget gallery. + private var galleryName: String { + LAAppGroupSettings.showDisplayName() ? LAAppGroupSettings.displayName() : "Loop Follow" + } + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: GlucoseWidgetConfigurationIntent.self, + provider: WidgetTimelineProvider() + ) { entry in + LoopFollowWidgetView(entry: entry) + } + .configurationDisplayName(galleryName) + .description("Glucose at a glance.") + .supportedFamilies([.systemMedium]) + // The chart runs to the edges, so the widget insets its own content. + .contentMarginsDisabled() + } +} diff --git a/LoopFollowWidget/LoopFollowWidgetBundle.swift b/LoopFollowWidget/LoopFollowWidgetBundle.swift new file mode 100644 index 000000000..fabae2c3d --- /dev/null +++ b/LoopFollowWidget/LoopFollowWidgetBundle.swift @@ -0,0 +1,12 @@ +// LoopFollow +// LoopFollowWidgetBundle.swift + +import SwiftUI +import WidgetKit + +@main +struct LoopFollowWidgetBundle: WidgetBundle { + var body: some Widget { + LoopFollowWidget() + } +} diff --git a/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift b/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift new file mode 100644 index 000000000..60ad4969a --- /dev/null +++ b/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift @@ -0,0 +1,227 @@ +// LoopFollow +// NightscoutDeviceStatusFetcher.swift + +import Foundation + +/// What one `/api/v1/devicestatus.json` record can say about the metrics beside +/// the reading. +/// +/// Everything the app builds out of treatments or the profile is missing here on +/// purpose: basal, override, carbs today, the site and sensor ages and the +/// profile name have no source in this response, and the widget writes them +/// empty rather than guessing. +struct NightscoutDeviceStatus { + var iob: Double? + var cob: Double? + var projected: Double? + var recBolus: Double? + var autosens: Double? + var tdd: Double? + var isfMgdlPerU: Double? + var carbRatio: Double? + var targetLowMgdl: Double? + var targetHighMgdl: Double? + var battery: Double? + var pumpBattery: Double? + var pumpReservoirU: Double? + + /// A pump record that carries no reservoir has one the pump does not number + /// yet, which is how the app reads the same absence. + var pumpReservoirAboveMax = false + var minBgMgdl: Double? + var maxBgMgdl: Double? + + /// When the loop last reported, from the pump clock the app keys on too. + var loopClock: Date? + + /// Same fifteen minute rule the app applies to the pump clock. A record + /// without one says nothing either way, which is what a site with no loop + /// behind it should say. + var isNotLooping: Bool { + guard let loopClock else { return false } + return Date().timeIntervalSince(loopClock) >= 15 * 60 + } +} + +/// Reads the loop's own numbers straight from Nightscout, for the widget's +/// refresh button. Written the way `NightscoutChartFetcher` is and for the same +/// reason: nothing in `NightscoutUtils` or `MainViewController`, where the app's +/// parsing lives, exists inside an extension. +/// +/// Both device shapes are handled. Loop posts under `loop`, OpenAPS and Trio +/// under `openaps`, and the two are told apart by which key is there. +enum NightscoutDeviceStatusFetcher { + static let timeout: TimeInterval = 4 + + /// Past this the record is no longer describing now, and the widget would be + /// putting one fresh age over a reading and a set of metrics that do not + /// share it. The metrics are dropped instead. + static let recordStaleAfter: TimeInterval = 15 * 60 + + // MARK: - Public API + + /// Nil means the request did not land. An empty array is not a failure: a + /// site with no loop uploading to it answers that way every time, and the + /// caller wants an empty set of metrics, not an error. + static func fetch(baseURL: String, token: String) async -> NightscoutDeviceStatus? { + guard let url = statusURL(baseURL: baseURL, token: token) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + let session = URLSession(configuration: configuration) + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } + guard let records = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let record = records.first + else { + return NightscoutDeviceStatus() + } + return parse(record) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + // MARK: - Parsing + + static func parse(_ record: [String: Any]) -> NightscoutDeviceStatus { + var status = NightscoutDeviceStatus() + + if let pump = record["pump"] as? [String: Any] { + status.loopClock = date(from: pump["clock"]) + status.pumpReservoirU = double(pump["reservoir"]) + status.pumpReservoirAboveMax = pump["reservoir"] == nil + if let battery = pump["battery"] as? [String: Any] { + status.pumpBattery = double(battery["percent"]) + } + } + if let uploader = record["uploader"] as? [String: Any] { + status.battery = double(uploader["battery"]) + } + + if let loop = record["loop"] as? [String: Any] { + apply(loop: loop, to: &status) + } + if let openaps = record["openaps"] as? [String: Any] { + apply(openaps: openaps, to: &status) + } + + // A record from a while back describes a moment the widget is not about + // to claim, so keep only what stays true across the gap. + if let clock = status.loopClock, Date().timeIntervalSince(clock) > recordStaleAfter { + let loopClock = status.loopClock + status = NightscoutDeviceStatus() + status.loopClock = loopClock + } + return status + } + + /// Loop keeps its numbers under nested objects and predicts a curve rather + /// than a single value, so the projection is the end of that curve. + private static func apply(loop: [String: Any], to status: inout NightscoutDeviceStatus) { + // A failed loop reported nothing to read, and the app skips the record + // wholesale in that case. + guard loop["failureReason"] == nil else { return } + + if let iob = loop["iob"] as? [String: Any] { + status.iob = double(iob["iob"]) + } + if let cob = loop["cob"] as? [String: Any] { + status.cob = double(cob["cob"]) + } + status.recBolus = double(loop["recommendedBolus"]) + + if let predicted = loop["predicted"] as? [String: Any], + let values = predicted["values"] as? [Double], !values.isEmpty + { + status.projected = values.last + status.minBgMgdl = values.min() + status.maxBgMgdl = values.max() + } + } + + /// OpenAPS and Trio put the working numbers on `suggested`, which the app + /// prefers over `enacted`, and the recommended bolus one level up. + private static func apply(openaps: [String: Any], to status: inout NightscoutDeviceStatus) { + if let iob = openaps["iob"] as? [String: Any] { + status.iob = double(iob["iob"]) + } + status.recBolus = double(openaps["recommendedBolus"]) + + let block = (openaps["suggested"] as? [String: Any]) ?? (openaps["enacted"] as? [String: Any]) + guard let block else { return } + + status.cob = double(block["COB"]) ?? scraped("COB", from: block["reason"]) + status.projected = double(block["eventualBG"]) + status.autosens = double(block["sensitivityRatio"]) + status.tdd = double(block["TDD"]) + status.isfMgdlPerU = double(block["ISF"]) + status.carbRatio = double(block["CR"]) ?? scraped("CR", from: block["reason"]) + + // One target, so both ends of the range carry it, the way the app does. + // Sites configured in mmol/L post it in mmol/L, and no glucose target is + // ever set as low as 40 mg/dL, so the small numbers are the converted ones. + if let target = double(block["current_target"]) { + let mgdl = target < 40 ? target * GlucoseConversion.mmolToMgDl : target + status.targetLowMgdl = mgdl + status.targetHighMgdl = mgdl + } + + if let predictions = block["predBGs"] as? [String: Any] { + let values = ["ZT", "IOB", "COB", "UAM"].compactMap { predictions[$0] as? [Double] }.flatMap { $0 } + if !values.isEmpty { + status.minBgMgdl = values.min() + status.maxBgMgdl = values.max() + } + } + } + + // MARK: - Helpers + + /// Some of what the loop reports is only ever stated in the prose it writes + /// alongside its numbers, so it is read out of there when the field is gone. + private static func scraped(_ label: String, from reason: Any?) -> Double? { + guard let reason = reason as? String, + let range = reason.range(of: "\(label): [0-9]+(\\.[0-9]+)?", options: .regularExpression) + else { return nil } + return Double(reason[range].dropFirst(label.count + 2)) + } + + private static func double(_ value: Any?) -> Double? { + (value as? NSNumber)?.doubleValue + } + + /// Uploaders differ on whether they print fractional seconds, and a formatter + /// that insists either way silently fails on half the sites. + private static func date(from value: Any?) -> Date? { + guard let text = value as? String else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: text) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: text) + } + + private static func statusURL(baseURL: String, token: String) -> URL? { + var components = URLComponents(string: baseURL) + components?.path = "/api/v1/devicestatus.json" + + var queryItems = [URLQueryItem]() + if !token.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: token)) + } + queryItems.append(URLQueryItem(name: "count", value: "1")) + components?.queryItems = queryItems + + return components?.url + } +} diff --git a/LoopFollowWidget/RefreshWidgetIntent.swift b/LoopFollowWidget/RefreshWidgetIntent.swift new file mode 100644 index 000000000..6d336c6a1 --- /dev/null +++ b/LoopFollowWidget/RefreshWidgetIntent.swift @@ -0,0 +1,248 @@ +// LoopFollow +// RefreshWidgetIntent.swift + +import AppIntents + +/// The widget's own refresh, run when the button along the base is tapped. +/// +/// This runs inside the widget extension, where none of the app's parsing is +/// reachable, so everything it writes is rebuilt out of the two Nightscout +/// responses it fetches here. The snapshot is rebuilt whole rather than patched: +/// the widget puts a single age over the reading and the metrics beside it, so a +/// field this cannot source is written empty and reads as unavailable. Carrying +/// the app's older value forward would restate it under a timestamp it has not +/// got, and the metrics would be older than the age line claims. +/// +/// What that costs, against what the app itself can show: basal, override, carbs +/// today, the sensor, cannula and insulin ages and the profile name come from +/// treatments and the profile, which are several more requests than a tap can +/// wait for, so those blocks fall back to their no value glyph until the app +/// next writes. Everything the loop posts to devicestatus survives the refresh. +/// +/// The reading is not the only thing a tap is asking after. A loop that has +/// failed, or has just recovered, turns over between two CGM readings, and that +/// gap is exactly when the button gets pressed, so the loop's own state is +/// refreshed on its own account as well. The reading that comes back with it +/// does not move in that case, and neither does the age drawn over it. +struct RefreshWidgetIntent: AppIntent { + static var title: LocalizedStringResource = "Refresh Glucose" + static var description = IntentDescription("Reads the latest glucose and loop status from Nightscout.") + + /// Reached only from the widget's own button, and it needs the widget's + /// process to write the App Group, so it neither opens the app nor offers + /// itself as a shortcut. + static var openAppWhenRun: Bool = false + static var isDiscoverable: Bool = false + + func perform() async throws -> some IntentResult { + let url = LAAppGroupSettings.nightscoutURL() + // The button is not drawn without a site to ask, so this is the case + // where one was removed between the render and the tap. + guard !url.isEmpty else { return .result() } + let token = LAAppGroupSettings.nightscoutToken() + + async let entriesTask = NightscoutChartFetcher.fetch(baseURL: url, token: token) + async let statusTask = NightscoutDeviceStatusFetcher.fetch(baseURL: url, token: token) + + let entries = await entriesTask + let status = await statusTask + + guard let entries, let reading = entries.reading, let status else { + LAAppGroupSettings.setRefreshFailed(at: Date()) + LAAppGroupSettings.clearRefreshChecked() + return .result() + } + + // A stored reading stamped ahead of this device cannot be ranked by age + // at all, since the clock that wrote it is wrong, so it does not get to + // hold off a reading that Nightscout is serving as the current one. + let stored = GlucoseSnapshotStore.shared.load() + let storedAt = stored?.updatedAt ?? .distantPast + let storedIsRankable = storedAt <= Date().addingTimeInterval(60) + let hasNewerReading = !storedIsRankable || reading.date > storedAt + + // Nothing to write when neither the reading nor the loop has moved on. + // The stored snapshot is at least as recent and carries the fields this + // cannot rebuild, so replacing it would only lose them. + // + // Nothing to write is still something to say, though: the reading and + // its age are about to redraw as they were, and without the note the tap + // is indistinguishable from one that did nothing at all. + let refreshed: GlucoseSnapshot + if hasNewerReading { + refreshed = snapshot(reading: reading, status: status) + } else if let stored, loopMoved(from: stored, to: status) { + refreshed = snapshot(carrying: stored, status: status) + } else { + LAAppGroupSettings.setRefreshFailed(at: nil) + LAAppGroupSettings.setRefreshChecked(at: Date(), broughtNewData: false) + WidgetRefreshOutcome.set(movedLoop: false) + return .result() + } + + // Chart first. Should the process be stopped between the two writes, a + // renewed chart beside the app's older snapshot is the state the widget + // already lives in whenever its cache runs stale, and the age line still + // describes the snapshot it is drawn from. The other order would put a + // fresh age over an old chart. + await save(entries.series) + await save(refreshed) + LAAppGroupSettings.setRefreshFailed(at: nil) + LAAppGroupSettings.setRefreshChecked(at: Date(), broughtNewData: hasNewerReading) + WidgetRefreshOutcome.set(movedLoop: !hasNewerReading) + + // WidgetKit reloads the timeline once this returns, so asking it to is + // a second reload for the same change. + return .result() + } + + // MARK: - What moved + + /// Whether a write is owed on the loop's account alone. + /// + /// Two ways it can be. The site can be serving a record later than the one + /// the stored snapshot was read from, which is a loop that has reported + /// since. Or the record can be the same one, aged past the point where it + /// still describes a running loop: a loop that stops does not report that it + /// has stopped, so nothing newer is ever going to arrive and the fifteen + /// minute verdict is the only thing that moves. + /// + /// Judged on the loop's own clock and on that verdict, never on the metrics. + /// The app and this fetcher read several of the same numbers out of different + /// keys and through different conversions, so one of them reading differently + /// is no evidence that anything changed, and acting on it would throw away + /// the app's fuller snapshot on every tap. + private func loopMoved(from stored: GlucoseSnapshot, to status: NightscoutDeviceStatus) -> Bool { + if status.isNotLooping != stored.isNotLooping { return true } + // A snapshot written before the clock was recorded cannot be ranked, so + // it is left alone until the app next writes one that can. + guard let fetched = status.loopClock, let known = stored.loopUpdatedAt else { return false } + return fetched > known + } + + // MARK: - Assembly + + private func snapshot(reading: NightscoutReading, status: NightscoutDeviceStatus) -> GlucoseSnapshot { + snapshot( + glucose: reading.mgdl, + delta: reading.deltaMgdl, + trend: Self.trend(from: reading.direction), + readAt: reading.date, + status: status + ) + } + + /// A loop only refresh. The reading, its delta and its trend are the stored + /// ones untouched, and so is the timestamp the age line counts from, so that + /// line goes on describing the same reading it described before the tap. The + /// rest is rebuilt from the fetch on exactly the terms a full refresh uses, + /// which is what keeps a field this cannot source from outliving the record + /// it came from. + private func snapshot(carrying stored: GlucoseSnapshot, status: NightscoutDeviceStatus) -> GlucoseSnapshot { + snapshot( + glucose: stored.glucose, + delta: stored.delta, + trend: stored.trend, + readAt: stored.updatedAt, + status: status + ) + } + + /// One assembly for both paths, so the reading half and the loop half cannot + /// drift apart in what they write. + private func snapshot( + glucose: Double, + delta: Double, + trend: GlucoseSnapshot.Trend, + readAt: Date, + status: NightscoutDeviceStatus + ) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: readAt, + loopUpdatedAt: status.loopClock, + iob: status.iob, + cob: status.cob, + projected: status.projected, + override: nil, + recBolus: status.recBolus, + battery: status.battery, + pumpBattery: status.pumpBattery, + basalRate: "", + pumpReservoirU: status.pumpReservoirU, + pumpReservoirAboveMax: status.pumpReservoirAboveMax, + autosens: status.autosens, + tdd: status.tdd, + targetLowMgdl: status.targetLowMgdl, + targetHighMgdl: status.targetHighMgdl, + isfMgdlPerU: status.isfMgdlPerU, + carbRatio: status.carbRatio, + carbsToday: nil, + profileName: nil, + sageInsertTime: 0, + cageInsertTime: 0, + iageInsertTime: 0, + minBgMgdl: status.minBgMgdl, + maxBgMgdl: status.maxBgMgdl, + unit: LAAppGroupSettings.preferredUnit(), + isNotLooping: status.isNotLooping + ) + } + + /// Nightscout's own spelling of the trend, matched the way the app's snapshot + /// builder matches it so the arrow does not change meaning between them. + private static func trend(from direction: String?) -> GlucoseSnapshot.Trend { + guard let direction = direction?.lowercased() else { return .unknown } + switch direction { + case "doubleup", "rapidrise", "up2", "upfast": return .upFast + case "fortyfiveup": return .upSlight + case "singleup", "up", "up1", "rising": return .up + case "flat", "steady", "none": return .flat + case "doubledown", "rapidfall", "down2", "downfast": return .downFast + case "fortyfivedown": return .downSlight + case "singledown", "down", "down1", "falling": return .down + default: return .unknown + } + } + + // MARK: - Storage + + /// Both stores write on their own queue, and the widget is redrawn the moment + /// this returns, so a fire and forget save would race the render. + private func save(_ series: GlucoseChartSeries) async { + await withCheckedContinuation { continuation in + GlucoseChartSeriesStore.shared.save(series) { continuation.resume() } + } + } + + private func save(_ snapshot: GlucoseSnapshot) async { + await withCheckedContinuation { continuation in + GlucoseSnapshotStore.shared.save(snapshot) { continuation.resume() } + } + } +} + +/// Whether the last refresh that landed had only the loop to report. It sits +/// beside the intent because it never leaves the extension: this is the only +/// thing that writes it and the timeline provider is the only thing that reads +/// it, both of them in the same process. +/// +/// Written on every path that marks a refresh as checked, so it can never be +/// read as the answer to an earlier tap than the one the timestamp names. +enum WidgetRefreshOutcome { + private static let key = "la.widget.refreshMovedLoop" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: AppGroupID.current()) + } + + static func set(movedLoop: Bool) { + defaults?.set(movedLoop, forKey: key) + } + + static func movedLoop() -> Bool { + defaults?.bool(forKey: key) ?? false + } +} diff --git a/LoopFollowWidget/WidgetChartView.swift b/LoopFollowWidget/WidgetChartView.swift new file mode 100644 index 000000000..0c11e286b --- /dev/null +++ b/LoopFollowWidget/WidgetChartView.swift @@ -0,0 +1,278 @@ +// LoopFollow +// WidgetChartView.swift + +import Charts +import SwiftUI +import WidgetKit + +/// Glucose scatter over the configured duration, drawn edge to edge as the +/// widget's backdrop. +/// +/// Values stay in mg/dL until `display(_:)` converts them to the user's unit. +struct WidgetChartView: View { + let series: GlucoseChartSeries + let unit: GlucoseSnapshot.Unit + let duration: WidgetChartDuration + var style: WidgetChartStyle = .standard + + /// Bands at the base and the top of the widget that the caller draws its own + /// text over. Nothing is plotted into them, so the threshold lines stay + /// readable wherever the data happens to sit. + var bottomReserve: CGFloat = 0 + var topReserve: CGFloat = 0 + + /// Tinted and clear appearances flatten the plot to one colour, so the marks + /// fall back to opacity for separation. + @Environment(\.widgetRenderingMode) private var renderingMode + + /// Headroom above and below the extremes, so points are not drawn on the edge. + private static let paddingMgdl: Double = 22 + + /// Extra headroom at the top, where the chart meets the widget's own edge. + private static let topPaddingMgdl: Double = 24 + + /// Narrowest Y domain, so flat data still reads as flat. + private static let minSpanMgdl: Double = 120 + + private var isFullColor: Bool { + renderingMode == .fullColor + } + + private var thresholds: (low: Double, high: Double) { + LAAppGroupSettings.thresholdsMgdl() + } + + /// Window drawn on the X axis. + private var window: TimeInterval { + duration.seconds + } + + /// Keeps the first and last marks clear of the widget's rounded corners. + private var edgeSlack: TimeInterval { + window * 0.02 + } + + /// Dots have to shrink as the span grows or the long windows read as a band. + private var symbolSize: Double { + switch duration { + case .oneHour, .threeHours, .sixHours: 16 + case .twelveHours: 12 + case .twentyFourHours: 9 + } + } + + private var lineWidth: Double { + switch duration { + case .oneHour, .threeHours, .sixHours: 2.6 + case .twelveHours: 2.2 + case .twentyFourHours: 1.8 + } + } + + /// Longer than this between two readings and the line is cut rather than + /// carried across: a curve drawn through a sensor dropout is history that + /// never happened. Three missed readings at the usual five minute cadence. + private static let maxGap: TimeInterval = 20 * 60 + + /// Every reading in the window is drawn. A day is a few hundred marks, well + /// inside what the chart handles, and thinning a glucose chart risks losing + /// the excursion that made it worth looking at. + private var points: [GlucoseChartPoint] { + let start = Date().addingTimeInterval(-window) + let visible = series.points.filter { $0.date >= start } + + // Marks are identified by the whole point, so a reading posted twice + // would collide and one of the two would go missing. + var deduped: [GlucoseChartPoint] = [] + for point in visible where point != deduped.last { + deduped.append(point) + } + return deduped + } + + private func display(_ mgdl: Double) -> Double { + switch unit { + case .mgdl: return mgdl + case .mmol: return GlucoseConversion.toMmol(mgdl) + } + } + + /// Follows the data but always contains both threshold lines, so nothing + /// charted is ever clipped out of view. + private func domainMgdl(for values: [Double], thresholds t: (low: Double, high: Double)) -> ClosedRange { + // Settings import accepts the thresholds unvalidated, so order them here: + // a range whose lower bound exceeds its upper one is a runtime trap. + let low = min(t.low, t.high) + let high = max(t.low, t.high) + let lower = min(values.min() ?? low, low) - Self.paddingMgdl + let upper = max(values.max() ?? high, high) + Self.topPaddingMgdl + guard upper - lower < Self.minSpanMgdl else { return lower ... upper } + + let middle = (lower + upper) / 2 + return (middle - Self.minSpanMgdl / 2) ... (middle + Self.minSpanMgdl / 2) + } + + /// Lifts the plotted range clear of the reserved bands: everything that has to + /// be seen is squeezed into the height between them, while the scale itself + /// still spans the whole view, so the chart keeps bleeding to all four edges. + private func plotted(_ content: ClosedRange, height: CGFloat) -> ClosedRange { + guard height > 0 else { return content } + let below = Double(bottomReserve / height) + let usable = 1 - below - Double(topReserve / height) + guard usable > 0.3 else { return content } + + let span = (content.upperBound - content.lowerBound) / usable + let lower = content.lowerBound - span * below + return lower ... (lower + span) + } + + /// Which threshold band a reading falls in, so a run of readings that share + /// one can be drawn as a single line in a single colour. + private func band(_ mgdl: Double, thresholds t: (low: Double, high: Double)) -> Int { + if mgdl < t.low { return -1 } else if mgdl > t.high { return 1 } else { return 0 } + } + + /// Splits the readings into stretches that can each be drawn as one line: a + /// new run starts wherever the colour changes or the sensor stopped + /// reporting. Runs that meet in time repeat the joining reading, so a change + /// of colour leaves no hole in the trace, while a gap does. + private func runs(_ visible: [GlucoseChartPoint], thresholds t: (low: Double, high: Double)) -> [[GlucoseChartPoint]] { + var result: [[GlucoseChartPoint]] = [] + var current: [GlucoseChartPoint] = [] + + for point in visible { + guard let previous = current.last else { + current = [point] + continue + } + if point.date.timeIntervalSince(previous.date) > Self.maxGap { + result.append(current) + current = [point] + } else if band(previous.value, thresholds: t) != band(point.value, thresholds: t) { + current.append(point) + result.append(current) + current = [point] + } else { + current.append(point) + } + } + if !current.isEmpty { result.append(current) } + return result + } + + private func color(forMgdl mgdl: Double, thresholds t: (low: Double, high: Double)) -> Color { + if mgdl < t.low { + return Color(.systemRed) + } else if mgdl > t.high { + return Color(.systemOrange) + } else { + return Color(.systemGreen) + } + } + + var body: some View { + GeometryReader { proxy in + chart(height: proxy.size.height) + } + } + + private func chart(height: CGFloat) -> some View { + let visible = points + let t = thresholds + + let now = Date() + let start = now.addingTimeInterval(-window - edgeSlack) + let end = max(now, visible.last?.date ?? now).addingTimeInterval(edgeSlack) + + let content = domainMgdl(for: visible.map(\.value), thresholds: t) + let domain = plotted(content, height: height) + + return Chart { + RuleMark(y: .value("High", display(t.high))) + .foregroundStyle(Color(.systemOrange).opacity(isFullColor ? 0.7 : 0.4)) + .lineStyle(.init(lineWidth: 1, dash: [4, 4])) + + RuleMark(y: .value("Low", display(t.low))) + .foregroundStyle(Color(.systemRed).opacity(isFullColor ? 0.7 : 0.4)) + .lineStyle(.init(lineWidth: 1, dash: [4, 4])) + + if style == .line { + ForEach(Array(runs(visible, thresholds: t).enumerated()), id: \.offset) { index, run in + // A reading left alone by a gap on both sides has no line to + // be part of, so it is drawn as the point it is. + if run.count == 1, let point = run.first { + PointMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.value)) + ) + .symbolSize(symbolSize) + .foregroundStyle(color(forMgdl: point.value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + } else { + ForEach(run, id: \.self) { point in + LineMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.value)), + series: .value("Run", index) + ) + } + // Monotone, not Catmull-Rom: a spline that overshoots + // would draw a dip below the low line that never + // happened. The colour comes from the run's own band. + .interpolationMethod(.monotone) + .lineStyle(.init(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .foregroundStyle(color(forMgdl: run[0].value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + } + } + } else { + ForEach(visible, id: \.self) { point in + PointMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.value)) + ) + .symbolSize(symbolSize) + .foregroundStyle(color(forMgdl: point.value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + } + } + } + .chartXScale(domain: start ... end) + .chartYScale(domain: display(domain.lowerBound) ... display(domain.upperBound)) + .chartXAxis { + AxisMarks(position: .automatic) { _ in + AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3])) + .foregroundStyle(Color.primary.opacity(isFullColor ? 0.16 : 0.25)) + } + } + .chartYAxis { + AxisMarks(position: .trailing) { _ in + AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3])) + .foregroundStyle(Color.primary.opacity(isFullColor ? 0.16 : 0.25)) + } + } + .chartPlotStyle { $0.frame(maxWidth: .infinity, maxHeight: .infinity) } + .overlay { + if visible.isEmpty { + // Centred in what the floating reading leaves free. + Text("No recent glucose") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, 110) + } + } + } +} + +#Preview { + let now = Date() + let values: [Double] = [64, 72, 88, 104, 126, 149, 171, 188, 196, 184, 160, 138] + let points = values.enumerated().map { index, value in + GlucoseChartPoint(value: value, date: now.addingTimeInterval(Double(index - values.count) * 300)) + } + + return WidgetChartView( + series: GlucoseChartSeries(points: points, updatedAt: now), + unit: .mgdl, + duration: .standard + ) + .frame(height: 103) +} diff --git a/LoopFollowWidget/WidgetConfigurationIntent.swift b/LoopFollowWidget/WidgetConfigurationIntent.swift new file mode 100644 index 000000000..6794ab318 --- /dev/null +++ b/LoopFollowWidget/WidgetConfigurationIntent.swift @@ -0,0 +1,97 @@ +// LoopFollow +// WidgetConfigurationIntent.swift + +import AppIntents + +extension LiveActivitySlotOption: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Metric" } + + // The AppIntents metadata processor only accepts a literal dictionary here, + // so these titles repeat `displayName` instead of deriving from it. + static var caseDisplayRepresentations: [LiveActivitySlotOption: DisplayRepresentation] { + [ + .none: "Empty", + .delta: "Delta", + .projectedBG: "Projected BG", + .minMax: "Min/Max", + .iob: "IOB", + .cob: "COB", + .recBolus: "Rec. Bolus", + .autosens: "Autosens", + .tdd: "TDD", + .basal: "Basal", + .pump: "Pump", + .pumpBattery: "Pump Battery", + .battery: "Battery", + .target: "Target", + .isf: "ISF", + .carbRatio: "CR", + .sage: "SAGE", + .cage: "CAGE", + .iage: "IAGE", + .carbsToday: "Carbs today", + .override: "Override", + .profile: "Profile", + ] + } +} + +extension WidgetChartDuration: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Duration" } + + // Literal for the same reason as the slot titles above. + static var caseDisplayRepresentations: [WidgetChartDuration: DisplayRepresentation] { + [ + .oneHour: "1h", + .threeHours: "3h", + .sixHours: "6h", + .twelveHours: "12h", + .twentyFourHours: "24h", + ] + } +} + +extension WidgetChartStyle: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Line Style" } + + // Literal for the same reason as the slot titles above. + static var caseDisplayRepresentations: [WidgetChartStyle: DisplayRepresentation] { + [ + .dots: "Dots", + .line: "Line", + ] + } +} + +/// Configuration presented by Edit Widget: the span of the chart and how it is +/// drawn, then one parameter per metric block, using the same options as the +/// Live Activity grid. There are three blocks: the fourth place along the base +/// is the refresh button. +struct GlucoseWidgetConfigurationIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Widget Options" } + static var description: IntentDescription { "Choose how the chart is drawn and the metrics shown beside it." } + + // Spelled out rather than read from WidgetChartDuration.standard, for the + // compile-time constant rule noted below; keep the two in step. + @Parameter(title: "Duration", default: .threeHours) + var duration: WidgetChartDuration + + // Spelled out rather than read from WidgetChartStyle.standard, same rule. + @Parameter(title: "Line Style", default: .dots) + var chartStyle: WidgetChartStyle + + // @Parameter defaults must be compile-time constants, so these are spelled + // out rather than read from LiveActivitySlotDefaults; keep the two in step. + @Parameter(title: "Slot 1", default: .iob) + var slot1: LiveActivitySlotOption + + @Parameter(title: "Slot 2", default: .cob) + var slot2: LiveActivitySlotOption + + @Parameter(title: "Slot 3", default: .projectedBG) + var slot3: LiveActivitySlotOption + + var slots: [LiveActivitySlotOption] { + [slot1, slot2, slot3] + } +} diff --git a/LoopFollowWidget/WidgetDataSource.swift b/LoopFollowWidget/WidgetDataSource.swift new file mode 100644 index 000000000..73dca275f --- /dev/null +++ b/LoopFollowWidget/WidgetDataSource.swift @@ -0,0 +1,31 @@ +// LoopFollow +// WidgetDataSource.swift + +import Foundation + +/// Reads what the app cached in the App Group and, when that has gone stale, +/// tops it up from Nightscout. Nothing here writes back to the cache. +enum WidgetDataSource { + /// Three missed five-minute readings. + static let staleAfter: TimeInterval = 15 * 60 + + static func load() async -> (series: GlucoseChartSeries?, snapshot: GlucoseSnapshot?) { + let cached = GlucoseChartSeriesStore.shared.load() + let snapshot = GlucoseSnapshotStore.shared.load() + + if let cached = cached, cached.age <= staleAfter { + return (cached, snapshot) + } + + let url = LAAppGroupSettings.nightscoutURL() + guard !url.isEmpty else { return (cached, snapshot) } + + // Entries cannot supply the snapshot's secondary metrics. + guard let fetched = await NightscoutChartFetcher.fetchSeries(baseURL: url, token: LAAppGroupSettings.nightscoutToken()), + (fetched.points.last?.date ?? .distantPast) >= (cached?.points.last?.date ?? .distantPast) + else { + return (cached, snapshot) + } + return (fetched, snapshot) + } +} diff --git a/LoopFollowWidget/WidgetInfo.plist b/LoopFollowWidget/WidgetInfo.plist new file mode 100644 index 000000000..fedac909f --- /dev/null +++ b/LoopFollowWidget/WidgetInfo.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + LoopFollowWidget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowWidget/WidgetSlotView.swift b/LoopFollowWidget/WidgetSlotView.swift new file mode 100644 index 000000000..0ff5e7c47 --- /dev/null +++ b/LoopFollowWidget/WidgetSlotView.swift @@ -0,0 +1,47 @@ +// LoopFollow +// WidgetSlotView.swift + +import SwiftUI + +/// One configurable metric block, mirroring the Live Activity grid cell but +/// sized for the home screen and using system colors instead of white on tint. +struct WidgetSlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot? + + /// These values come from the same snapshot as the glucose reading, so they + /// are demoted alongside it rather than reading as current. + let isStale: Bool + + private var value: String { + guard let snapshot else { return "—" } + return slotFormattedValue(option: option, snapshot: snapshot) + } + + var body: some View { + if option == .none { + // Height is pinned: an unconstrained Color would claim the whole + // widget and push the metric row off its edge. + Color.clear + .frame(maxWidth: .infinity, maxHeight: 0) + } else { + VStack(alignment: .leading, spacing: 0) { + Text(option.gridLabel.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .tracking(0.4) + .foregroundStyle(isStale ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.secondary)) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Text(value) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(isStale ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .allowsTightening(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift new file mode 100644 index 000000000..8788083c0 --- /dev/null +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -0,0 +1,149 @@ +// LoopFollow +// WidgetTimelineProvider.swift + +import WidgetKit + +/// Supplies the home screen widget from the App Group caches written by the app. +/// +/// Entries carry the same data at advancing dates, so the reading keeps aging +/// into its stale state even when WidgetKit cannot afford to reload us. +struct WidgetTimelineProvider: AppIntentTimelineProvider { + private static let refreshInterval: TimeInterval = 5 * 60 + + /// WidgetKit keeps drawing the last entry once the timeline runs out, so a + /// short timeline makes an old reading claim to be recent. Cover a stretch + /// long enough that a suspended app cannot hide hours of silence: every five + /// minutes for the first hour, then every fifteen out to the horizon. + private static let horizon: TimeInterval = 4 * 3600 + + private static let entryOffsets: [TimeInterval] = { + let fine = stride(from: 0, to: 3600, by: refreshInterval).map { $0 } + let coarse = stride(from: 3600, through: horizon, by: 15 * 60).map { $0 } + return fine + coarse + }() + + /// Everything a tap puts on screen has to clear well inside the five minutes + /// between the ordinary entries, so the timeline gets one extra entry at each + /// moment one of them is due to go. Entries are what WidgetKit redraws from, + /// and asking it for a reload on a timer is not something it grants, so an + /// interval with no entry at its end is an interval that does not end. + /// + /// This is also what guarantees each state a minimum time on screen. The + /// deadlines are seconds apart while the ordinary run is minutes apart, so a + /// state cannot be skipped by a redraw that lands between them. + private static func offsets(expiringIn deadlines: [TimeInterval?]) -> [TimeInterval] { + let extra = deadlines + .compactMap { $0 } + .filter { $0 > 0 && $0 < horizon && !entryOffsets.contains($0) } + guard !extra.isEmpty else { return entryOffsets } + return (entryOffsets + extra).sorted() + } + + /// The refresh fetches from Nightscout, so a setup without one has nothing + /// for the button to do. + private static var canRefresh: Bool { + !LAAppGroupSettings.nightscoutURL().isEmpty + } + + func placeholder(in _: Context) -> GlucoseWidgetEntry { + Self.sampleEntry(slots: LiveActivitySlotDefaults.widget, duration: .standard, style: .standard) + } + + func snapshot(for configuration: GlucoseWidgetConfigurationIntent, in context: Context) async -> GlucoseWidgetEntry { + if context.isPreview { + return Self.sampleEntry(slots: configuration.slots, duration: configuration.duration, style: configuration.chartStyle) + } + let (series, snapshot) = await WidgetDataSource.load() + return GlucoseWidgetEntry( + date: Date(), + series: series, + snapshot: snapshot, + slots: configuration.slots, + duration: configuration.duration, + chartStyle: configuration.chartStyle, + canRefresh: Self.canRefresh, + refreshFailedAt: LAAppGroupSettings.refreshFailedAt(), + refreshCheckedAt: LAAppGroupSettings.refreshCheckedAt(), + refreshBroughtNewData: LAAppGroupSettings.refreshBroughtNewData(), + refreshMovedLoop: WidgetRefreshOutcome.movedLoop() + ) + } + + func timeline(for configuration: GlucoseWidgetConfigurationIntent, in _: Context) async -> Timeline { + let now = Date() + let (series, snapshot) = await WidgetDataSource.load() + // Read once and carried on every entry, so the later ones age out of the + // failure window on their own rather than needing a reload to clear it. + let refreshFailedAt = LAAppGroupSettings.refreshFailedAt() + let refreshCheckedAt = LAAppGroupSettings.refreshCheckedAt() + let refreshBroughtNewData = LAAppGroupSettings.refreshBroughtNewData() + let refreshMovedLoop = WidgetRefreshOutcome.movedLoop() + let canRefresh = Self.canRefresh + + // One deadline per thing a tap leaves on screen: the button's own + // acknowledgement, and the wording that outlasts it. + let deadlines: [TimeInterval?] = [ + refreshCheckedAt?.addingTimeInterval(GlucoseWidgetEntry.confirmationWindow).timeIntervalSince(now), + refreshCheckedAt?.addingTimeInterval(GlucoseWidgetEntry.buttonFlashWindow).timeIntervalSince(now), + refreshFailedAt?.addingTimeInterval(GlucoseWidgetEntry.buttonFlashWindow).timeIntervalSince(now), + ] + + let entries = Self.offsets(expiringIn: deadlines).map { offset in + GlucoseWidgetEntry( + date: now.addingTimeInterval(offset), + series: series, + snapshot: snapshot, + slots: configuration.slots, + duration: configuration.duration, + chartStyle: configuration.chartStyle, + canRefresh: canRefresh, + refreshFailedAt: refreshFailedAt, + refreshCheckedAt: refreshCheckedAt, + refreshBroughtNewData: refreshBroughtNewData, + refreshMovedLoop: refreshMovedLoop + ) + } + + return Timeline(entries: entries, policy: .after(now.addingTimeInterval(Self.refreshInterval))) + } + + // MARK: - Gallery sample + + private static func sampleEntry(slots: [LiveActivitySlotOption], duration: WidgetChartDuration, style: WidgetChartStyle) -> GlucoseWidgetEntry { + let now = Date() + // Spread across whatever span was picked, so the gallery preview fills + // its chart at every duration. + let values: [Double] = [96, 104, 112, 118, 121, 126, 133, 141, 148, 152, 149, 142, 136, 131, 127, 124] + let spacing = duration.seconds / Double(values.count) + let points = values.enumerated().map { index, value in + GlucoseChartPoint(value: value, date: now.addingTimeInterval(Double(index - values.count + 1) * spacing)) + } + + let snapshot = GlucoseSnapshot( + glucose: 124, + delta: -3, + trend: .downSlight, + updatedAt: now, + iob: 1.2, + cob: 18, + projected: 118, + recBolus: 0.35, + basalRate: "0.75 U/hr", + tdd: 24.6, + targetLowMgdl: 100, + targetHighMgdl: 100, + unit: .mgdl, + isNotLooping: false + ) + + return GlucoseWidgetEntry( + date: now, + series: GlucoseChartSeries(points: points, updatedAt: now), + snapshot: snapshot, + slots: slots, + duration: duration, + chartStyle: style, + canRefresh: canRefresh + ) + } +} diff --git a/LoopFollowWidgetExtension.entitlements b/LoopFollowWidgetExtension.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift index 95563dafc..b4d108a63 100644 --- a/RestartLiveActivityIntent.swift +++ b/RestartLiveActivityIntent.swift @@ -33,6 +33,20 @@ } } + @available(iOS 16.4, *) + struct StopLiveActivityIntent: AppIntent { + static var title: LocalizedStringResource = "Stop Live Activity" + static var description = IntentDescription("Ends the LoopFollow Live Activity and keeps it off until restarted.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = false + + await MainActor.run { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } + + return .result(dialog: "Live Activity stopped.") + } + } + @available(iOS 16.4, *) struct LoopFollowAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { @@ -42,6 +56,12 @@ shortTitle: "Restart Live Activity", systemImageName: "dot.radiowaves.left.and.right" ) + AppShortcut( + intent: StopLiveActivityIntent(), + phrases: ["Stop Live Activity in \(.applicationName)"], + shortTitle: "Stop Live Activity", + systemImageName: "stop.circle" + ) } } #endif diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 120b9f061..e8253bcca 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -56,7 +56,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.LoopFollowWidget" ] ) @@ -78,6 +79,13 @@ platform :ios do targets: ["LoopFollowLAExtensionExtension"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowWidget"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWidgetExtension"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", @@ -88,7 +96,8 @@ platform :ios do export_options: { provisioningProfiles: { "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + "com.#{TEAMID}.LoopFollow.LoopFollowWidget" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowWidget"] } } ) @@ -147,6 +156,10 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS ]) + configure_bundle_id("LoopFollow Widget Extension", "com.#{TEAMID}.LoopFollow.LoopFollowWidget", [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + end desc "Provision Certificates" @@ -167,7 +180,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.LoopFollowWidget" ] ) end