diff --git a/Common/Locked.swift b/Common/Locked.swift index 206b2a5..849328d 100644 --- a/Common/Locked.swift +++ b/Common/Locked.swift @@ -31,6 +31,7 @@ public class Locked { } } + @discardableResult public func mutate(_ changes: (_ value: inout T) -> Void) -> T { os_unfair_lock_lock(&lock) defer { os_unfair_lock_unlock(&lock) } diff --git a/G6SensorKit.xcodeproj/project.pbxproj b/G6SensorKit.xcodeproj/project.pbxproj index 1fcbec7..9a87e0e 100644 --- a/G6SensorKit.xcodeproj/project.pbxproj +++ b/G6SensorKit.xcodeproj/project.pbxproj @@ -7,81 +7,82 @@ objects = { /* Begin PBXBuildFile section */ - 0012909C39D62B9E08C9B064 /* G6SensorKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */; }; - 04A2B67E2547E628203F1358 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 429E660585726604E67AECFD /* Localizable.xcstrings */; }; - 04B8053FDEAE43229A81457E /* G6CGMManager+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3CDBC2872D089CCFC608914 /* G6CGMManager+UI.swift */; }; - 1E4F189DD2133923C1AFE92E /* G6OnboardingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A74B38BAA7E148D962602A /* G6OnboardingViews.swift */; }; - 1F73E2F944A55A9AC25A90D2 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */; }; - 20ADEEB69E1914A498A8288C /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E317B6199E7FDA3530EB43 /* LocalizedString.swift */; }; - 2B177BC4AA7B5AC20A0C1CF0 /* G6SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3226DB4412DBC72DFFA90E /* G6SettingsView.swift */; }; - 348D78E88E7AB61A3EA8B5A6 /* G6DetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F3FBE9FF2D0785F9F703D83 /* G6DetailViews.swift */; }; - 3B345FE82B0B128320B404C0 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BE9888BF3A30CE450E970741 /* Localizable.xcstrings */; }; - 3D9CC651E9C531C097620F89 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 253838547314B57604F7AD40 /* OSLog.swift */; }; - 43A3BB61380CE04F8ADF97F5 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */; }; - 43CCBACB96EC5B4551CE5E7A /* G6ReadingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC5A15D4A759F997F7FC541 /* G6ReadingDetailView.swift */; }; - 44180D8F790DF0B7AD6858A6 /* G6CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED4B45A9BCAFD7FD14DE50A /* G6CGMManager.swift */; }; - 5124B64274AC3FE6A2C96D55 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; }; - 55F5DF80BE7C11261D02F92D /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */; }; - 5C0BDAD9F8F46B1894220D75 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = B534C9F9F094DA2E641E1F56 /* HKUnit.swift */; }; - 60CA6A4323498F0AA5733921 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F64DEB4B031D1C5CFE809317 /* Foundation.framework */; }; - 6B46867393D9926E754A0B1C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F64DEB4B031D1C5CFE809317 /* Foundation.framework */; }; - 6B717441841D031D0B28D767 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F64DEB4B031D1C5CFE809317 /* Foundation.framework */; }; - 75FB2E8B0FA7123C2ED8DAE4 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; }; - 80A0E78EA46BE2777673109D /* G6SensorKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 8BB678C1F2BD1D88FA1CFBBC /* G6SimulatedSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74783BF907EE99207B30B578 /* G6SimulatedSession.swift */; }; - 8FF638A85A7E67CA3BC5D9F9 /* G6Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03906F90369E7A2BF4DED672 /* G6Alert.swift */; }; - A69AF218C10E883AEB62C3BD /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */; }; - A81FAA3A42066C630B773A7A /* G6SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - A8E3935009CCE36B2536A64C /* G6ApplySensorViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76466FB9DD611978BC655632 /* G6ApplySensorViews.swift */; }; - A9E3573682AB3B40EC374CA7 /* G6CalibrationGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE94E843AB267DF79DBE2E3 /* G6CalibrationGuideView.swift */; }; - AF6D1CDCC2147D486CC666B0 /* G6AlertEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58664EFC566ECB3D78CCFAF /* G6AlertEvaluator.swift */; }; - B379B66CE81079E346AC7C74 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCBBEB66782BF39C6C015CB /* LocalizedString.swift */; }; - B62C7DB071276B15C354E424 /* G6ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5694A019EAD8454DABAA13 /* G6ActivityViewController.swift */; }; - B920AFAF7C31F1E389D677D6 /* G6UICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB91998500832B7983C5050 /* G6UICoordinator.swift */; }; - B974B8BBB65603583E2AA9B6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D79C9A64B80B4CC5B3DE78 /* HKUnit.swift */; }; - C2A26CF0F1BB14468B041B94 /* G6Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD441CA0C87A3361D4ABC996 /* G6Graphics.swift */; }; - C402E99C66F01260013FBB1A /* G6ShareCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C22FE5054AF7D52948DDD28 /* G6ShareCredentialStore.swift */; }; - CDBF362147EF86005600539C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF491D549C3EF35172BD575E /* Assets.xcassets */; }; - E733B5AF9AC6DDC38B92A330 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */; }; - EB7246F1D6D512CC2D28B53C /* G6CGMManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFDEDB952EC717B78C87578 /* G6CGMManagerState.swift */; }; - F605B5DD6F6B98C7BA18BE26 /* G6SensorKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7985708466B1C12CA524C462 /* G6SensorKitPlugin.swift */; }; - F63FF74AB43CFAC6FA5BB9F1 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2737F7376B1AEBD38191DE68 /* Locked.swift */; }; - FCEF70D7853D669835A2C2A2 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEFF2008F7747472D563331E /* TimeInterval.swift */; }; + 032019A6EEC8573370EE4E4E /* G6SensorKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 737C6C35530CD8BB42B84DF0 /* G6SensorKitPlugin.swift */; }; + 03A90C3DAF1C15F423EBB537 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D91984A1B779118EF08A4127 /* G6SensorKit.framework */; }; + 089143CB98FE2468E2B3D99A /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E6BF919306BACA2E97E323D4 /* Foundation.framework */; }; + 0B96FC48B098E283212D7434 /* G6ShareCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F673B89751D3C7E3998139B9 /* G6ShareCredentialStore.swift */; }; + 1443E4605DC4562A4C2A7BEC /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76A719F98226D490448343A /* LoopKitUI.framework */; }; + 19DCE7B50E1D4DB6785C0764 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E6BF919306BACA2E97E323D4 /* Foundation.framework */; }; + 1EFFB6CA1A4DD2702593640A /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B414B1B8987E102920EBB8 /* HKUnit.swift */; }; + 1F0E56CFDD57BC9269932E00 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907230D65E10C39D822408AD /* LocalizedString.swift */; }; + 256CCEB29BA96551FCEC4B4A /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 17A6D5E4FD73994C5B1F06F5 /* Localizable.xcstrings */; }; + 325836DA81824456AC495CFF /* G6Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E75012965DC1C6990A2500 /* G6Graphics.swift */; }; + 3383256660F7228702BAA719 /* G6CGMManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB151CF01D474F2595EF5CA /* G6CGMManagerState.swift */; }; + 38145114C2273AD696A3D633 /* G6AlertEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C23F573526681B9A23C06AB /* G6AlertEvaluator.swift */; }; + 38402F888769F9D025B8CE04 /* G6ApplySensorViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DABEF600E765FA27D449C7 /* G6ApplySensorViews.swift */; }; + 388CA00B3F572757ADD0A58D /* G6OnboardingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92908880134DF0809C232DA8 /* G6OnboardingViews.swift */; }; + 3A9FBCDBD2078C788E4D6A61 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 7F5C107F2A9FBB346DAC8840 /* Localizable.xcstrings */; }; + 3DAE7BECBFE06CFB97DDD196 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD562B4C4652CF07A6B07073 /* LocalizedString.swift */; }; + 3E90A28BA6A527167A190026 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D91984A1B779118EF08A4127 /* G6SensorKit.framework */; }; + 428978D53D25A29DAAED79BB /* G6ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B0EA77B0F64BDC3B3EEEC6 /* G6ActivityViewController.swift */; }; + 430C49804080723BD78C8DBA /* G6SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1009E258B325CFF8CFB4E87A /* G6SettingsView.swift */; }; + 43274E4597B3482D10EECF54 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90F75E1970D7A80176F9FB9F /* LoopKit.framework */; }; + 504856D070F911796810BCC9 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E6BF919306BACA2E97E323D4 /* Foundation.framework */; }; + 5DB32B111CC9558D9D17F1DD /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E433F3990FF78C38DC6551 /* OSLog.swift */; }; + 5E74E403BEF0BB27F23087A0 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90F75E1970D7A80176F9FB9F /* LoopKit.framework */; }; + 6C3243405AE5D59AEF654CB7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A559C2D3AD9EBB43248B2199 /* Assets.xcassets */; }; + 829F7AA37876027EACE3A83C /* G6SensorKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 606033721DAAF7F6793184DB /* G6SensorKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 92984720FAB46BCCAB93C2F4 /* G6CalibrationGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F285089CA85DF822BC3527 /* G6CalibrationGuideView.swift */; }; + 94284FD983F0E91AE79A91B6 /* G6PackageScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 704C5C6E0A049E4425DCDC45 /* G6PackageScannerView.swift */; }; + 96FE9CAA2BA79D899055E8B0 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8541E3B5A43131113BCA24C3 /* TimeInterval.swift */; }; + A9A6EA0AADF90B3D2BC174BE /* G6CGMManager+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F317421564304E728293A980 /* G6CGMManager+UI.swift */; }; + AE16E074483DAC2574FA59C5 /* G6SensorKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 606033721DAAF7F6793184DB /* G6SensorKitUI.framework */; }; + BF338B66F1042236F4DBAAA0 /* G6Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B21A00EB9363194AD4E3DC4 /* G6Alert.swift */; }; + C1105244C23AE85D192C3C0D /* G6SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D91984A1B779118EF08A4127 /* G6SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + CF28F3F0F438EE53CD7CCEFE /* G6UICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D0041A85C2E5CE22F9FB07 /* G6UICoordinator.swift */; }; + D389B8A224241FF1DC55A0B5 /* G6SimulatedSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1EFCFD2903EB844B045366 /* G6SimulatedSession.swift */; }; + D3B56173754CE3AA5CB8CEA6 /* G6CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9097D3ECD4FB9E24FB170C75 /* G6CGMManager.swift */; }; + D5272301CD6F360B8E8F2D54 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76A719F98226D490448343A /* LoopKitUI.framework */; }; + D5456F7B45319478DB67D043 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90F75E1970D7A80176F9FB9F /* LoopKit.framework */; }; + D57D16D172751A2E34BCBE2D /* G6DetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A236522437951B0C651E1483 /* G6DetailViews.swift */; }; + E2EF5851A11BCE61A474C191 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182061E2189A3DB8D5EC94F2 /* Locked.swift */; }; + EFC856B31EB45F398EF25CA6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7BA7FD280EA993E40E1D9A /* HKUnit.swift */; }; + F38D4C7E56F03FD1B2E7F924 /* G6ReadingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4FA59134AF1F66A4E60292 /* G6ReadingDetailView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 393AD5FC1C4D642198DBF3BA /* PBXContainerItemProxy */ = { + 1BBFE840668BC082C5C18328 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = E00046664E8B19B1D108CD7E /* Project object */; proxyType = 1; - remoteGlobalIDString = 3ADDB0CD055A33C01C7B54FE; - remoteInfo = G6SensorKitUI; + remoteGlobalIDString = 1FC930737F45A671AE16478F; + remoteInfo = G6SensorKit; }; - 5DCC41D3B8AE0AF30CAAD2E3 /* PBXContainerItemProxy */ = { + 23DF82B0A16EFF5164752461 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = E00046664E8B19B1D108CD7E /* Project object */; proxyType = 1; - remoteGlobalIDString = 1B26058BD31E40741B0058A5; - remoteInfo = G6SensorKit; + remoteGlobalIDString = 5FDFF54B3F109CF9EB807CE7; + remoteInfo = G6SensorKitUI; }; - CC0AE83086E9B970A7244CCE /* PBXContainerItemProxy */ = { + E0A5C942CCB714A7E897F6AA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = E00046664E8B19B1D108CD7E /* Project object */; proxyType = 1; - remoteGlobalIDString = 1B26058BD31E40741B0058A5; + remoteGlobalIDString = 1FC930737F45A671AE16478F; remoteInfo = G6SensorKit; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 4FFBC74F2EB40DC4B5ED7514 /* Embed Frameworks */ = { + EC6909781D72EEC70B1A43CA /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - A81FAA3A42066C630B773A7A /* G6SensorKit.framework in Embed Frameworks */, - 80A0E78EA46BE2777673109D /* G6SensorKitUI.framework in Embed Frameworks */, + C1105244C23AE85D192C3C0D /* G6SensorKit.framework in Embed Frameworks */, + 829F7AA37876027EACE3A83C /* G6SensorKitUI.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -89,194 +90,196 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 03906F90369E7A2BF4DED672 /* G6Alert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6Alert.swift; sourceTree = ""; }; - 0BC5A15D4A759F997F7FC541 /* G6ReadingDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ReadingDetailView.swift; path = Views/G6ReadingDetailView.swift; sourceTree = ""; }; - 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 1DB91998500832B7983C5050 /* G6UICoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6UICoordinator.swift; sourceTree = ""; }; - 253838547314B57604F7AD40 /* OSLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; - 2737F7376B1AEBD38191DE68 /* Locked.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; - 2C5694A019EAD8454DABAA13 /* G6ActivityViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ActivityViewController.swift; path = Views/G6ActivityViewController.swift; sourceTree = ""; }; - 429E660585726604E67AECFD /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; - 48E317B6199E7FDA3530EB43 /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; - 4F3226DB4412DBC72DFFA90E /* G6SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6SettingsView.swift; path = Views/G6SettingsView.swift; sourceTree = ""; }; - 5F3FBE9FF2D0785F9F703D83 /* G6DetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6DetailViews.swift; path = Views/G6DetailViews.swift; sourceTree = ""; }; - 5FCBBEB66782BF39C6C015CB /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; - 6ED4B45A9BCAFD7FD14DE50A /* G6CGMManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManager.swift; sourceTree = ""; }; - 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 74783BF907EE99207B30B578 /* G6SimulatedSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SimulatedSession.swift; sourceTree = ""; }; - 76466FB9DD611978BC655632 /* G6ApplySensorViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ApplySensorViews.swift; path = Views/G6ApplySensorViews.swift; sourceTree = ""; }; - 7985708466B1C12CA524C462 /* G6SensorKitPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SensorKitPlugin.swift; sourceTree = ""; }; - 89D79C9A64B80B4CC5B3DE78 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; - 8C22FE5054AF7D52948DDD28 /* G6ShareCredentialStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6ShareCredentialStore.swift; sourceTree = ""; }; - A9A74B38BAA7E148D962602A /* G6OnboardingViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6OnboardingViews.swift; path = Views/G6OnboardingViews.swift; sourceTree = ""; }; - ADFDEDB952EC717B78C87578 /* G6CGMManagerState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManagerState.swift; sourceTree = ""; }; - B534C9F9F094DA2E641E1F56 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; - B58664EFC566ECB3D78CCFAF /* G6AlertEvaluator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6AlertEvaluator.swift; sourceTree = ""; }; - BE9888BF3A30CE450E970741 /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; - BEFF2008F7747472D563331E /* TimeInterval.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimeInterval.swift; sourceTree = ""; }; - C3CDBC2872D089CCFC608914 /* G6CGMManager+UI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G6CGMManager+UI.swift"; sourceTree = ""; }; - CFE94E843AB267DF79DBE2E3 /* G6CalibrationGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6CalibrationGuideView.swift; path = Views/G6CalibrationGuideView.swift; sourceTree = ""; }; - F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitPlugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F64DEB4B031D1C5CFE809317 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - FD441CA0C87A3361D4ABC996 /* G6Graphics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6Graphics.swift; path = Views/G6Graphics.swift; sourceTree = ""; }; - FF491D549C3EF35172BD575E /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 02E433F3990FF78C38DC6551 /* OSLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + 1009E258B325CFF8CFB4E87A /* G6SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6SettingsView.swift; path = Views/G6SettingsView.swift; sourceTree = ""; }; + 15B0EA77B0F64BDC3B3EEEC6 /* G6ActivityViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ActivityViewController.swift; path = Views/G6ActivityViewController.swift; sourceTree = ""; }; + 17A6D5E4FD73994C5B1F06F5 /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; + 182061E2189A3DB8D5EC94F2 /* Locked.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; + 18B414B1B8987E102920EBB8 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + 2B21A00EB9363194AD4E3DC4 /* G6Alert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6Alert.swift; sourceTree = ""; }; + 47E75012965DC1C6990A2500 /* G6Graphics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6Graphics.swift; path = Views/G6Graphics.swift; sourceTree = ""; }; + 51F285089CA85DF822BC3527 /* G6CalibrationGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6CalibrationGuideView.swift; path = Views/G6CalibrationGuideView.swift; sourceTree = ""; }; + 5B4FA59134AF1F66A4E60292 /* G6ReadingDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ReadingDetailView.swift; path = Views/G6ReadingDetailView.swift; sourceTree = ""; }; + 606033721DAAF7F6793184DB /* G6SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 704C5C6E0A049E4425DCDC45 /* G6PackageScannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6PackageScannerView.swift; path = Views/G6PackageScannerView.swift; sourceTree = ""; }; + 737C6C35530CD8BB42B84DF0 /* G6SensorKitPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SensorKitPlugin.swift; sourceTree = ""; }; + 7B1EFCFD2903EB844B045366 /* G6SimulatedSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SimulatedSession.swift; sourceTree = ""; }; + 7C23F573526681B9A23C06AB /* G6AlertEvaluator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6AlertEvaluator.swift; sourceTree = ""; }; + 7F5C107F2A9FBB346DAC8840 /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; + 8541E3B5A43131113BCA24C3 /* TimeInterval.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimeInterval.swift; sourceTree = ""; }; + 8A7BA7FD280EA993E40E1D9A /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + 8CB151CF01D474F2595EF5CA /* G6CGMManagerState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManagerState.swift; sourceTree = ""; }; + 907230D65E10C39D822408AD /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + 9097D3ECD4FB9E24FB170C75 /* G6CGMManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManager.swift; sourceTree = ""; }; + 90F75E1970D7A80176F9FB9F /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 92908880134DF0809C232DA8 /* G6OnboardingViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6OnboardingViews.swift; path = Views/G6OnboardingViews.swift; sourceTree = ""; }; + A236522437951B0C651E1483 /* G6DetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6DetailViews.swift; path = Views/G6DetailViews.swift; sourceTree = ""; }; + A559C2D3AD9EBB43248B2199 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A76A719F98226D490448343A /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A7D0041A85C2E5CE22F9FB07 /* G6UICoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6UICoordinator.swift; sourceTree = ""; }; + CD562B4C4652CF07A6B07073 /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + D91984A1B779118EF08A4127 /* G6SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E6BF919306BACA2E97E323D4 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + F0DABEF600E765FA27D449C7 /* G6ApplySensorViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ApplySensorViews.swift; path = Views/G6ApplySensorViews.swift; sourceTree = ""; }; + F317421564304E728293A980 /* G6CGMManager+UI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G6CGMManager+UI.swift"; sourceTree = ""; }; + F673B89751D3C7E3998139B9 /* G6ShareCredentialStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6ShareCredentialStore.swift; sourceTree = ""; }; + F9D12B501A84ED6C12CFFF6A /* G6SensorKitPlugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitPlugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 08CFF0A2C0AF7A01359571D3 /* Frameworks */ = { + 036BF4C74764238F023DA0E8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6B46867393D9926E754A0B1C /* Foundation.framework in Frameworks */, - A69AF218C10E883AEB62C3BD /* LoopKit.framework in Frameworks */, - 55F5DF80BE7C11261D02F92D /* LoopKitUI.framework in Frameworks */, - 5124B64274AC3FE6A2C96D55 /* G6SensorKit.framework in Frameworks */, - 0012909C39D62B9E08C9B064 /* G6SensorKitUI.framework in Frameworks */, + 089143CB98FE2468E2B3D99A /* Foundation.framework in Frameworks */, + D5456F7B45319478DB67D043 /* LoopKit.framework in Frameworks */, + 1443E4605DC4562A4C2A7BEC /* LoopKitUI.framework in Frameworks */, + 3E90A28BA6A527167A190026 /* G6SensorKit.framework in Frameworks */, + AE16E074483DAC2574FA59C5 /* G6SensorKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 4BBD731E15A4EBB02214C814 /* Frameworks */ = { + 3C582B67957BD8AC20A789A7 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 60CA6A4323498F0AA5733921 /* Foundation.framework in Frameworks */, - E733B5AF9AC6DDC38B92A330 /* LoopKit.framework in Frameworks */, + 504856D070F911796810BCC9 /* Foundation.framework in Frameworks */, + 5E74E403BEF0BB27F23087A0 /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 7034F2C68438154861EB8766 /* Frameworks */ = { + 95916C8AF6C891370D4B46A3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6B717441841D031D0B28D767 /* Foundation.framework in Frameworks */, - 1F73E2F944A55A9AC25A90D2 /* LoopKit.framework in Frameworks */, - 43A3BB61380CE04F8ADF97F5 /* LoopKitUI.framework in Frameworks */, - 75FB2E8B0FA7123C2ED8DAE4 /* G6SensorKit.framework in Frameworks */, + 19DCE7B50E1D4DB6785C0764 /* Foundation.framework in Frameworks */, + 43274E4597B3482D10EECF54 /* LoopKit.framework in Frameworks */, + D5272301CD6F360B8E8F2D54 /* LoopKitUI.framework in Frameworks */, + 03A90C3DAF1C15F423EBB537 /* G6SensorKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0B2EBDB688CC9F72D51B8750 /* Products */ = { + 19A5E64B61E7B02E7A128E2A = { isa = PBXGroup; children = ( - F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */, - 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */, - F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */, + 7E3A0A5E82CBF0BEB5AC2BC6 /* G6SensorKit */, + 7EF5BB2F037C379D46B3D521 /* Common */, + 65A7A5C24641BA7AF03178DB /* G6SensorKitUI */, + 1E84BEF7FF7C5428825C9733 /* G6SensorKitPlugin */, + CB6896C5F55260956ACC3EAF /* Products */, + A6BF578ECCAF8B60CE3BB16F /* Frameworks */, ); - name = Products; sourceTree = ""; }; - 1DCBFFA30E3C8A0058AA3F9E /* Common */ = { + 1E84BEF7FF7C5428825C9733 /* G6SensorKitPlugin */ = { isa = PBXGroup; children = ( - 89D79C9A64B80B4CC5B3DE78 /* HKUnit.swift */, - 48E317B6199E7FDA3530EB43 /* LocalizedString.swift */, - 2737F7376B1AEBD38191DE68 /* Locked.swift */, - 253838547314B57604F7AD40 /* OSLog.swift */, - BEFF2008F7747472D563331E /* TimeInterval.swift */, - B534C9F9F094DA2E641E1F56 /* HKUnit.swift */, + 737C6C35530CD8BB42B84DF0 /* G6SensorKitPlugin.swift */, ); - name = Common; - path = Common; + name = G6SensorKitPlugin; + path = G6SensorKitPlugin; sourceTree = ""; }; - 473EA52459AC5B5F384D87B9 /* iOS */ = { + 65A7A5C24641BA7AF03178DB /* G6SensorKitUI */ = { isa = PBXGroup; children = ( - F64DEB4B031D1C5CFE809317 /* Foundation.framework */, + F317421564304E728293A980 /* G6CGMManager+UI.swift */, + A7D0041A85C2E5CE22F9FB07 /* G6UICoordinator.swift */, + 907230D65E10C39D822408AD /* LocalizedString.swift */, + 15B0EA77B0F64BDC3B3EEEC6 /* G6ActivityViewController.swift */, + F0DABEF600E765FA27D449C7 /* G6ApplySensorViews.swift */, + 51F285089CA85DF822BC3527 /* G6CalibrationGuideView.swift */, + A236522437951B0C651E1483 /* G6DetailViews.swift */, + 47E75012965DC1C6990A2500 /* G6Graphics.swift */, + 92908880134DF0809C232DA8 /* G6OnboardingViews.swift */, + 704C5C6E0A049E4425DCDC45 /* G6PackageScannerView.swift */, + 5B4FA59134AF1F66A4E60292 /* G6ReadingDetailView.swift */, + 1009E258B325CFF8CFB4E87A /* G6SettingsView.swift */, + 7F5C107F2A9FBB346DAC8840 /* Localizable.xcstrings */, + A559C2D3AD9EBB43248B2199 /* Assets.xcassets */, ); - name = iOS; + name = G6SensorKitUI; + path = G6SensorKitUI; sourceTree = ""; }; - 96B57708CA5A86D6435C7B27 /* Frameworks */ = { + 7E3A0A5E82CBF0BEB5AC2BC6 /* G6SensorKit */ = { isa = PBXGroup; children = ( - F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */, - 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */, - 473EA52459AC5B5F384D87B9 /* iOS */, + 2B21A00EB9363194AD4E3DC4 /* G6Alert.swift */, + 7C23F573526681B9A23C06AB /* G6AlertEvaluator.swift */, + 9097D3ECD4FB9E24FB170C75 /* G6CGMManager.swift */, + 8CB151CF01D474F2595EF5CA /* G6CGMManagerState.swift */, + F673B89751D3C7E3998139B9 /* G6ShareCredentialStore.swift */, + 7B1EFCFD2903EB844B045366 /* G6SimulatedSession.swift */, + 17A6D5E4FD73994C5B1F06F5 /* Localizable.xcstrings */, ); - name = Frameworks; + name = G6SensorKit; + path = G6SensorKit; sourceTree = ""; }; - BC0A237992092A7C2BE9B0C1 /* G6SensorKit */ = { + 7EF5BB2F037C379D46B3D521 /* Common */ = { isa = PBXGroup; children = ( - 03906F90369E7A2BF4DED672 /* G6Alert.swift */, - B58664EFC566ECB3D78CCFAF /* G6AlertEvaluator.swift */, - 6ED4B45A9BCAFD7FD14DE50A /* G6CGMManager.swift */, - ADFDEDB952EC717B78C87578 /* G6CGMManagerState.swift */, - 8C22FE5054AF7D52948DDD28 /* G6ShareCredentialStore.swift */, - 74783BF907EE99207B30B578 /* G6SimulatedSession.swift */, - BE9888BF3A30CE450E970741 /* Localizable.xcstrings */, + 18B414B1B8987E102920EBB8 /* HKUnit.swift */, + CD562B4C4652CF07A6B07073 /* LocalizedString.swift */, + 182061E2189A3DB8D5EC94F2 /* Locked.swift */, + 02E433F3990FF78C38DC6551 /* OSLog.swift */, + 8541E3B5A43131113BCA24C3 /* TimeInterval.swift */, + 8A7BA7FD280EA993E40E1D9A /* HKUnit.swift */, ); - name = G6SensorKit; - path = G6SensorKit; + name = Common; + path = Common; sourceTree = ""; }; - C15E860FAD47DBE3D41E4EC3 /* G6SensorKitPlugin */ = { + A6BF578ECCAF8B60CE3BB16F /* Frameworks */ = { isa = PBXGroup; children = ( - 7985708466B1C12CA524C462 /* G6SensorKitPlugin.swift */, + 90F75E1970D7A80176F9FB9F /* LoopKit.framework */, + A76A719F98226D490448343A /* LoopKitUI.framework */, + DF2BE94BD2F3F23A12B6F7C4 /* iOS */, ); - name = G6SensorKitPlugin; - path = G6SensorKitPlugin; + name = Frameworks; sourceTree = ""; }; - CC50F2BDBEBFB7D6BD059422 /* G6SensorKitUI */ = { + CB6896C5F55260956ACC3EAF /* Products */ = { isa = PBXGroup; children = ( - C3CDBC2872D089CCFC608914 /* G6CGMManager+UI.swift */, - 1DB91998500832B7983C5050 /* G6UICoordinator.swift */, - 5FCBBEB66782BF39C6C015CB /* LocalizedString.swift */, - 2C5694A019EAD8454DABAA13 /* G6ActivityViewController.swift */, - 76466FB9DD611978BC655632 /* G6ApplySensorViews.swift */, - CFE94E843AB267DF79DBE2E3 /* G6CalibrationGuideView.swift */, - 5F3FBE9FF2D0785F9F703D83 /* G6DetailViews.swift */, - FD441CA0C87A3361D4ABC996 /* G6Graphics.swift */, - A9A74B38BAA7E148D962602A /* G6OnboardingViews.swift */, - 0BC5A15D4A759F997F7FC541 /* G6ReadingDetailView.swift */, - 4F3226DB4412DBC72DFFA90E /* G6SettingsView.swift */, - 429E660585726604E67AECFD /* Localizable.xcstrings */, - FF491D549C3EF35172BD575E /* Assets.xcassets */, + D91984A1B779118EF08A4127 /* G6SensorKit.framework */, + 606033721DAAF7F6793184DB /* G6SensorKitUI.framework */, + F9D12B501A84ED6C12CFFF6A /* G6SensorKitPlugin.framework */, ); - name = G6SensorKitUI; - path = G6SensorKitUI; + name = Products; sourceTree = ""; }; - FA22E5F750CC96151439CB10 = { + DF2BE94BD2F3F23A12B6F7C4 /* iOS */ = { isa = PBXGroup; children = ( - BC0A237992092A7C2BE9B0C1 /* G6SensorKit */, - 1DCBFFA30E3C8A0058AA3F9E /* Common */, - CC50F2BDBEBFB7D6BD059422 /* G6SensorKitUI */, - C15E860FAD47DBE3D41E4EC3 /* G6SensorKitPlugin */, - 0B2EBDB688CC9F72D51B8750 /* Products */, - 96B57708CA5A86D6435C7B27 /* Frameworks */, + E6BF919306BACA2E97E323D4 /* Foundation.framework */, ); + name = iOS; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ - 46CF0A3AEA3068EC92793F50 /* Headers */ = { + 60A6AA3FCCB11141F8B8C24E /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 8871D9EC41336D32DC93F0A1 /* Headers */ = { + A7501DCDA29A942C68578914 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - D0DCBEE1011D380E0DAB9521 /* Headers */ = { + B35B2AD03E597E11E2A0B16A /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( @@ -286,14 +289,14 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ - 1B26058BD31E40741B0058A5 /* G6SensorKit */ = { + 1FC930737F45A671AE16478F /* G6SensorKit */ = { isa = PBXNativeTarget; - buildConfigurationList = 3C02D248834BA3228A2A0985 /* Build configuration list for PBXNativeTarget "G6SensorKit" */; + buildConfigurationList = E6BECE0CEC895BB0D3C1EA93 /* Build configuration list for PBXNativeTarget "G6SensorKit" */; buildPhases = ( - D0DCBEE1011D380E0DAB9521 /* Headers */, - 8BC23E60728EBBC538BC019C /* Sources */, - 4BBD731E15A4EBB02214C814 /* Frameworks */, - 840346A90686729F6FA69249 /* Resources */, + 60A6AA3FCCB11141F8B8C24E /* Headers */, + 444C5BA0EDD6B24D16C899CF /* Sources */, + 3C582B67957BD8AC20A789A7 /* Frameworks */, + A720A1114AADAE11791259CE /* Resources */, ); buildRules = ( ); @@ -301,62 +304,62 @@ ); name = G6SensorKit; packageProductDependencies = ( - 7B648B7A7143DAA0CE2F4FF9 /* G6SensorCore */, + 99E200D18B601AFB565F1B5B /* G6SensorCore */, ); productName = G6SensorKit; - productReference = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; + productReference = D91984A1B779118EF08A4127 /* G6SensorKit.framework */; productType = "com.apple.product-type.framework"; }; - 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */ = { + 5FDFF54B3F109CF9EB807CE7 /* G6SensorKitUI */ = { isa = PBXNativeTarget; - buildConfigurationList = 0871B09F7297CFA1E6F3ADCB /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */; + buildConfigurationList = CD0E963482CD3D2B5682140B /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */; buildPhases = ( - 8871D9EC41336D32DC93F0A1 /* Headers */, - 488FB4793D06FDDE73E64813 /* Sources */, - 7034F2C68438154861EB8766 /* Frameworks */, - FF4CE3D3A177620D8B97B7A2 /* Resources */, + A7501DCDA29A942C68578914 /* Headers */, + 8417137A0C4F1F7F25185E61 /* Sources */, + 95916C8AF6C891370D4B46A3 /* Frameworks */, + A456440235B07EA3B55C3C44 /* Resources */, ); buildRules = ( ); dependencies = ( - 17B55B286FE0A660013A668D /* PBXTargetDependency */, + 43D4F281FBAD16F8FCAE2F77 /* PBXTargetDependency */, ); name = G6SensorKitUI; productName = G6SensorKitUI; - productReference = 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */; + productReference = 606033721DAAF7F6793184DB /* G6SensorKitUI.framework */; productType = "com.apple.product-type.framework"; }; - EDCCBEADE99E54A732DD551F /* G6SensorKitPlugin */ = { + D59705DF2CF01716CBDBCA1E /* G6SensorKitPlugin */ = { isa = PBXNativeTarget; - buildConfigurationList = 5C9202DE59969D7F24D99477 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */; + buildConfigurationList = 92650F7E6B063572AC79D169 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */; buildPhases = ( - 46CF0A3AEA3068EC92793F50 /* Headers */, - A4FF0114F6295250522E88B0 /* Sources */, - 08CFF0A2C0AF7A01359571D3 /* Frameworks */, - BD1DA6A29C055EAE3F534D12 /* Resources */, - 4FFBC74F2EB40DC4B5ED7514 /* Embed Frameworks */, + B35B2AD03E597E11E2A0B16A /* Headers */, + E14BEE3FDC217ED133742420 /* Sources */, + 036BF4C74764238F023DA0E8 /* Frameworks */, + 1384F63BC3916A99F4309AD9 /* Resources */, + EC6909781D72EEC70B1A43CA /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( - 07F9FA8A5643278CD0C1E3DC /* PBXTargetDependency */, - 53F05EC0188A4EBC5A8F5A4F /* PBXTargetDependency */, + 15B834AB9E65E9AFA58F1744 /* PBXTargetDependency */, + 7A4F1CA8724DC60FA58D2552 /* PBXTargetDependency */, ); name = G6SensorKitPlugin; productName = G6SensorKitPlugin; - productReference = F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */; + productReference = F9D12B501A84ED6C12CFFF6A /* G6SensorKitPlugin.framework */; productType = "com.apple.product-type.framework"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - 9254155A3D36B6309735ED9B /* Project object */ = { + E00046664E8B19B1D108CD7E /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; }; - buildConfigurationList = 1F831DF837A28B0445B1871F /* Build configuration list for PBXProject "G6SensorKit" */; + buildConfigurationList = 1CB4E131373959E0BC530F1F /* Build configuration list for PBXProject "G6SensorKit" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; @@ -364,121 +367,122 @@ en, Base, ); - mainGroup = FA22E5F750CC96151439CB10; + mainGroup = 19A5E64B61E7B02E7A128E2A; minimizedProjectReferenceProxies = 0; packageReferences = ( - 17A8E591A82F95E6476AA1C9 /* XCLocalSwiftPackageReference "." */, + 459883F123D879CFB6DBB549 /* XCLocalSwiftPackageReference "." */, ); - preferredProjectObjectVersion = 77; - productRefGroup = 0B2EBDB688CC9F72D51B8750 /* Products */; + preferredProjectObjectVersion = 100; + productRefGroup = CB6896C5F55260956ACC3EAF /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 1B26058BD31E40741B0058A5 /* G6SensorKit */, - 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */, - EDCCBEADE99E54A732DD551F /* G6SensorKitPlugin */, + 1FC930737F45A671AE16478F /* G6SensorKit */, + 5FDFF54B3F109CF9EB807CE7 /* G6SensorKitUI */, + D59705DF2CF01716CBDBCA1E /* G6SensorKitPlugin */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 840346A90686729F6FA69249 /* Resources */ = { + 1384F63BC3916A99F4309AD9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3B345FE82B0B128320B404C0 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - BD1DA6A29C055EAE3F534D12 /* Resources */ = { + A456440235B07EA3B55C3C44 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3A9FBCDBD2078C788E4D6A61 /* Localizable.xcstrings in Resources */, + 6C3243405AE5D59AEF654CB7 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - FF4CE3D3A177620D8B97B7A2 /* Resources */ = { + A720A1114AADAE11791259CE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 04A2B67E2547E628203F1358 /* Localizable.xcstrings in Resources */, - CDBF362147EF86005600539C /* Assets.xcassets in Resources */, + 256CCEB29BA96551FCEC4B4A /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 488FB4793D06FDDE73E64813 /* Sources */ = { + 444C5BA0EDD6B24D16C899CF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 04B8053FDEAE43229A81457E /* G6CGMManager+UI.swift in Sources */, - B920AFAF7C31F1E389D677D6 /* G6UICoordinator.swift in Sources */, - B379B66CE81079E346AC7C74 /* LocalizedString.swift in Sources */, - B62C7DB071276B15C354E424 /* G6ActivityViewController.swift in Sources */, - A8E3935009CCE36B2536A64C /* G6ApplySensorViews.swift in Sources */, - A9E3573682AB3B40EC374CA7 /* G6CalibrationGuideView.swift in Sources */, - 348D78E88E7AB61A3EA8B5A6 /* G6DetailViews.swift in Sources */, - C2A26CF0F1BB14468B041B94 /* G6Graphics.swift in Sources */, - 1E4F189DD2133923C1AFE92E /* G6OnboardingViews.swift in Sources */, - 43CCBACB96EC5B4551CE5E7A /* G6ReadingDetailView.swift in Sources */, - 2B177BC4AA7B5AC20A0C1CF0 /* G6SettingsView.swift in Sources */, - 5C0BDAD9F8F46B1894220D75 /* HKUnit.swift in Sources */, + BF338B66F1042236F4DBAAA0 /* G6Alert.swift in Sources */, + 38145114C2273AD696A3D633 /* G6AlertEvaluator.swift in Sources */, + D3B56173754CE3AA5CB8CEA6 /* G6CGMManager.swift in Sources */, + 3383256660F7228702BAA719 /* G6CGMManagerState.swift in Sources */, + 0B96FC48B098E283212D7434 /* G6ShareCredentialStore.swift in Sources */, + D389B8A224241FF1DC55A0B5 /* G6SimulatedSession.swift in Sources */, + 1EFFB6CA1A4DD2702593640A /* HKUnit.swift in Sources */, + 3DAE7BECBFE06CFB97DDD196 /* LocalizedString.swift in Sources */, + E2EF5851A11BCE61A474C191 /* Locked.swift in Sources */, + 5DB32B111CC9558D9D17F1DD /* OSLog.swift in Sources */, + 96FE9CAA2BA79D899055E8B0 /* TimeInterval.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 8BC23E60728EBBC538BC019C /* Sources */ = { + 8417137A0C4F1F7F25185E61 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8FF638A85A7E67CA3BC5D9F9 /* G6Alert.swift in Sources */, - AF6D1CDCC2147D486CC666B0 /* G6AlertEvaluator.swift in Sources */, - 44180D8F790DF0B7AD6858A6 /* G6CGMManager.swift in Sources */, - EB7246F1D6D512CC2D28B53C /* G6CGMManagerState.swift in Sources */, - C402E99C66F01260013FBB1A /* G6ShareCredentialStore.swift in Sources */, - 8BB678C1F2BD1D88FA1CFBBC /* G6SimulatedSession.swift in Sources */, - B974B8BBB65603583E2AA9B6 /* HKUnit.swift in Sources */, - 20ADEEB69E1914A498A8288C /* LocalizedString.swift in Sources */, - F63FF74AB43CFAC6FA5BB9F1 /* Locked.swift in Sources */, - 3D9CC651E9C531C097620F89 /* OSLog.swift in Sources */, - FCEF70D7853D669835A2C2A2 /* TimeInterval.swift in Sources */, + A9A6EA0AADF90B3D2BC174BE /* G6CGMManager+UI.swift in Sources */, + CF28F3F0F438EE53CD7CCEFE /* G6UICoordinator.swift in Sources */, + 1F0E56CFDD57BC9269932E00 /* LocalizedString.swift in Sources */, + 428978D53D25A29DAAED79BB /* G6ActivityViewController.swift in Sources */, + 38402F888769F9D025B8CE04 /* G6ApplySensorViews.swift in Sources */, + 92984720FAB46BCCAB93C2F4 /* G6CalibrationGuideView.swift in Sources */, + D57D16D172751A2E34BCBE2D /* G6DetailViews.swift in Sources */, + 325836DA81824456AC495CFF /* G6Graphics.swift in Sources */, + 388CA00B3F572757ADD0A58D /* G6OnboardingViews.swift in Sources */, + 94284FD983F0E91AE79A91B6 /* G6PackageScannerView.swift in Sources */, + F38D4C7E56F03FD1B2E7F924 /* G6ReadingDetailView.swift in Sources */, + 430C49804080723BD78C8DBA /* G6SettingsView.swift in Sources */, + EFC856B31EB45F398EF25CA6 /* HKUnit.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - A4FF0114F6295250522E88B0 /* Sources */ = { + E14BEE3FDC217ED133742420 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F605B5DD6F6B98C7BA18BE26 /* G6SensorKitPlugin.swift in Sources */, + 032019A6EEC8573370EE4E4E /* G6SensorKitPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 07F9FA8A5643278CD0C1E3DC /* PBXTargetDependency */ = { + 15B834AB9E65E9AFA58F1744 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKit; - target = 1B26058BD31E40741B0058A5 /* G6SensorKit */; - targetProxy = 5DCC41D3B8AE0AF30CAAD2E3 /* PBXContainerItemProxy */; + target = 1FC930737F45A671AE16478F /* G6SensorKit */; + targetProxy = E0A5C942CCB714A7E897F6AA /* PBXContainerItemProxy */; }; - 17B55B286FE0A660013A668D /* PBXTargetDependency */ = { + 43D4F281FBAD16F8FCAE2F77 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKit; - target = 1B26058BD31E40741B0058A5 /* G6SensorKit */; - targetProxy = CC0AE83086E9B970A7244CCE /* PBXContainerItemProxy */; + target = 1FC930737F45A671AE16478F /* G6SensorKit */; + targetProxy = 1BBFE840668BC082C5C18328 /* PBXContainerItemProxy */; }; - 53F05EC0188A4EBC5A8F5A4F /* PBXTargetDependency */ = { + 7A4F1CA8724DC60FA58D2552 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKitUI; - target = 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */; - targetProxy = 393AD5FC1C4D642198DBF3BA /* PBXContainerItemProxy */; + target = 5FDFF54B3F109CF9EB807CE7 /* G6SensorKitUI */; + targetProxy = 23DF82B0A16EFF5164752461 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 000A188CB4AC3C534F7DEDA5 /* Debug */ = { + 48313512C607D6345E05EA05 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -486,7 +490,7 @@ CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = NO; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = NO; + DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -495,82 +499,61 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = G6SensorKitPlugin/Info.plist; + INFOPLIST_FILE = G6SensorKitUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WRAPPER_EXTENSION = loopplugin; }; - name = Debug; + name = Release; }; - 0A0532B628C24522B116C2FD /* Release */ = { + 5F47B21649870C5B6887C09E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = NO; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)", + ); + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = G6SensorKitPlugin/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; - VALIDATE_PRODUCT = YES; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + WRAPPER_EXTENSION = loopplugin; }; - name = Release; + name = Debug; }; - 1AE1D80C0367AD875A928588 /* Release */ = { + 6B7F966D3443B894BE4C93D1 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -578,7 +561,7 @@ CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = NO; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; + DEFINES_MODULE = NO; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -587,12 +570,13 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKitUI/Info.plist; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = G6SensorKitPlugin/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -602,10 +586,11 @@ VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; + WRAPPER_EXTENSION = loopplugin; }; name = Release; }; - 222C333070D0F8651A5DDC09 /* Debug */ = { + 7309CF4E6FD8C75F7C6FC8D1 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -656,7 +641,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -667,7 +652,7 @@ }; name = Debug; }; - 98F6F8109B911D276CB9A52E /* Debug */ = { + 8B0271A75292B6CE385B7ABD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -686,7 +671,7 @@ ); INFOPLIST_FILE = G6SensorKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKit; @@ -696,46 +681,69 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; - name = Debug; + name = Release; }; - D9853CD9CF2489E939E9B1B7 /* Debug */ = { + A3B0558276FC30AF2089ACE6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = NO; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(BUILT_PRODUCTS_DIR)", - ); - INFOPLIST_FILE = G6SensorKitUI/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.9; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; + VALIDATE_PRODUCT = YES; }; - name = Debug; + name = Release; }; - F5F1150AA306D577B24CACB2 /* Release */ = { + BA27D1028CA339E51C052640 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -752,25 +760,24 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKit/Info.plist; + INFOPLIST_FILE = G6SensorKitUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKit; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitUI; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; - VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; - name = Release; + name = Debug; }; - FC3AF832A0B14A9957D5966C /* Release */ = { + DCB9941481C5322FDDBB0801 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -778,7 +785,7 @@ CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = NO; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = NO; + DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -787,61 +794,58 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = G6SensorKitPlugin/Info.plist; + INFOPLIST_FILE = G6SensorKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKit; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = 1; - VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WRAPPER_EXTENSION = loopplugin; }; - name = Release; + name = Debug; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0871B09F7297CFA1E6F3ADCB /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */ = { + 1CB4E131373959E0BC530F1F /* Build configuration list for PBXProject "G6SensorKit" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1AE1D80C0367AD875A928588 /* Release */, - D9853CD9CF2489E939E9B1B7 /* Debug */, + 7309CF4E6FD8C75F7C6FC8D1 /* Debug */, + A3B0558276FC30AF2089ACE6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 1F831DF837A28B0445B1871F /* Build configuration list for PBXProject "G6SensorKit" */ = { + 92650F7E6B063572AC79D169 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( - 222C333070D0F8651A5DDC09 /* Debug */, - 0A0532B628C24522B116C2FD /* Release */, + 6B7F966D3443B894BE4C93D1 /* Release */, + 5F47B21649870C5B6887C09E /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 3C02D248834BA3228A2A0985 /* Build configuration list for PBXNativeTarget "G6SensorKit" */ = { + CD0E963482CD3D2B5682140B /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */ = { isa = XCConfigurationList; buildConfigurations = ( - F5F1150AA306D577B24CACB2 /* Release */, - 98F6F8109B911D276CB9A52E /* Debug */, + 48313512C607D6345E05EA05 /* Release */, + BA27D1028CA339E51C052640 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5C9202DE59969D7F24D99477 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */ = { + E6BECE0CEC895BB0D3C1EA93 /* Build configuration list for PBXNativeTarget "G6SensorKit" */ = { isa = XCConfigurationList; buildConfigurations = ( - FC3AF832A0B14A9957D5966C /* Release */, - 000A188CB4AC3C534F7DEDA5 /* Debug */, + 8B0271A75292B6CE385B7ABD /* Release */, + DCB9941481C5322FDDBB0801 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -849,18 +853,18 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 17A8E591A82F95E6476AA1C9 /* XCLocalSwiftPackageReference "." */ = { + 459883F123D879CFB6DBB549 /* XCLocalSwiftPackageReference "." */ = { isa = XCLocalSwiftPackageReference; relativePath = .; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 7B648B7A7143DAA0CE2F4FF9 /* G6SensorCore */ = { + 99E200D18B601AFB565F1B5B /* G6SensorCore */ = { isa = XCSwiftPackageProductDependency; productName = G6SensorCore; }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = 9254155A3D36B6309735ED9B /* Project object */; + rootObject = E00046664E8B19B1D108CD7E /* Project object */; } diff --git a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme index 6c3c107..6717617 100644 --- a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme +++ b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> diff --git a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme index 7fedca5..6c0d16d 100644 --- a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme +++ b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitPlugin.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> diff --git a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme index ad990c8..ed560ca 100644 --- a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme +++ b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKitUI.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> diff --git a/G6SensorKit/G6Alert.swift b/G6SensorKit/G6Alert.swift index 3f48063..173b826 100644 --- a/G6SensorKit/G6Alert.swift +++ b/G6SensorKit/G6Alert.swift @@ -16,6 +16,7 @@ import Foundation import LoopKit +import G6SensorCore enum G6Alert: String, CaseIterable { case sensorExpiringSoon @@ -29,6 +30,8 @@ enum G6Alert: String, CaseIterable { case transmitterBatteryLow case transmitterBatteryVeryLow case lastSessionForTransmitter + case commandDelayed + case commandFailed init?(rawValue: Alert.AlertIdentifier) { guard let match = Self.allCases.first(where: { $0.rawValue == rawValue }) else { @@ -55,7 +58,9 @@ enum G6Alert: String, CaseIterable { .signalLoss, .calibrationNeeded, .transmitterExpired, - .transmitterBatteryVeryLow: + .transmitterBatteryVeryLow, + .commandDelayed, + .commandFailed: return .timeSensitive case .sensorFailed: return .critical @@ -86,6 +91,10 @@ enum G6Alert: String, CaseIterable { return LocalizedString("Transmitter battery very low", comment: "Alert title for a very low transmitter battery") case .lastSessionForTransmitter: return LocalizedString("Last session for this transmitter", comment: "Alert title when a transmitter cannot fit another full session") + case .commandDelayed: + return LocalizedString("Transmitter not responding", comment: "Alert title when a queued command has waited several connection cycles") + case .commandFailed: + return LocalizedString("Request not sent", comment: "Alert title when a command failed to send or expired unsent") } } @@ -113,13 +122,50 @@ enum G6Alert: String, CaseIterable { return LocalizedString("Your transmitter's battery is very low and it may stop sending readings at any time, even mid-session. Replace the transmitter as soon as you can, and check your glucose with a fingerstick meter if readings stop.", comment: "Alert body for a very low transmitter battery") case .lastSessionForTransmitter: return LocalizedString("Your transmitter does not have enough life left for another full sensor session after this one. Have a replacement transmitter ready before this sensor ends.", comment: "Alert body when a transmitter cannot fit another full session") + case .commandDelayed: + return LocalizedString("A request is still waiting for your transmitter. It is sent as soon as the transmitter connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.", comment: "Alert body when a queued command has waited several connection cycles") + case .commandFailed: + return LocalizedString("A request could not be sent to your transmitter and will not be retried. Open the CGM settings and try again.", comment: "Alert body when a command failed to send") } } - func alert(managerIdentifier: String) -> Alert { + /// The body, naming the command when the alert is about one. What the + /// user needs to do differs: a stop or start leaves the session in the + /// wrong state, a lost calibration needs a fresh fingerstick. + func body(delayed: Command?, undelivered: UndeliveredCommand?) -> String { + switch self { + case .commandDelayed: + switch delayed { + case .stopSensor?: + return LocalizedString("Your sensor session has not stopped yet: the request is still waiting for your transmitter and is sent as soon as it connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.", comment: "Alert body when a session stop has waited several connection cycles") + case .startSensor?: + return LocalizedString("Your new sensor session has not started yet: the request is still waiting for your transmitter and is sent as soon as it connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.", comment: "Alert body when a session start has waited several connection cycles") + default: + return body + } + case .commandFailed: + guard let undelivered = undelivered else { + return body + } + switch undelivered.entry.command { + case .stopSensor: + return LocalizedString("Your sensor session was not stopped: the request could not be sent to your transmitter and will not be retried. Open the CGM settings and stop the session again, with your phone close to the transmitter.", comment: "Alert body when a session stop failed to send") + case .startSensor: + return LocalizedString("Your new sensor session was not started: the request could not be sent to your transmitter and will not be retried. Open the CGM settings and start the sensor again, with your phone close to the transmitter.", comment: "Alert body when a session start failed to send") + case .calibrateSensor: + return LocalizedString("Your calibration was not applied: it could not be sent to your transmitter in time and will not be retried. If you still want to calibrate, take a new fingerstick and enter it.", comment: "Alert body when a calibration failed to send or expired unsent") + case .resetTransmitter: + return body + } + default: + return body + } + } + + func alert(managerIdentifier: String, delayed: Command? = nil, undelivered: UndeliveredCommand? = nil) -> Alert { let content = Alert.Content( title: title, - body: body, + body: body(delayed: delayed, undelivered: undelivered), acknowledgeActionButtonLabel: LocalizedString("OK", comment: "Alert acknowledgment button label") ) diff --git a/G6SensorKit/G6AlertEvaluator.swift b/G6SensorKit/G6AlertEvaluator.swift index e3ceabd..2ae47c6 100644 --- a/G6SensorKit/G6AlertEvaluator.swift +++ b/G6SensorKit/G6AlertEvaluator.swift @@ -95,15 +95,29 @@ extension G6CGMManager { } } - reconcileAlerts(due: due) + // A flagged command still queued well past the usual connection gap. + // Retracted once it goes out or is withdrawn. + let delayed = CommandQueue(rawValues: state.pendingCommands).firstDelayed(now: now) + if delayed != nil { + due.insert(.commandDelayed) + } + + // A flagged command that failed or expired, until the user has seen + // the alert or the situation has resolved itself. + let undelivered = state.outstandingUndeliveredCommand + if let undelivered = undelivered, !undelivered.acknowledged { + due.insert(.commandFailed) + } + + reconcileAlerts(due: due, delayed: delayed?.command, undelivered: undelivered) } - private func reconcileAlerts(due: Set) { + private func reconcileAlerts(due: Set, delayed: Command?, undelivered: UndeliveredCommand?) { let previously = raisedAlerts for alert in due.subtracting(previously) { log.default("Issuing alert: %{public}@", alert.rawValue) - let issued = alert.alert(managerIdentifier: Self.pluginIdentifier) + let issued = alert.alert(managerIdentifier: Self.pluginIdentifier, delayed: delayed, undelivered: undelivered) delegateForAlerts.notify { delegate in delegate?.issueAlert(issued) } diff --git a/G6SensorKit/G6CGMManager.swift b/G6SensorKit/G6CGMManager.swift index 93d4516..96aec2d 100644 --- a/G6SensorKit/G6CGMManager.swift +++ b/G6SensorKit/G6CGMManager.swift @@ -69,10 +69,14 @@ public final class G6CGMManager: CGMManager { public private(set) var session: TransmitterSession? /// Commands queued for the next connection cycle. - private let lockedPendingCommands: Locked<[Command]> = Locked([]) + private let lockedCommandQueue: Locked = Locked(CommandQueue()) private var lastBatteryReadDate: Date? + /// The command handed to the session and not yet answered. Held so a + /// flagged command whose send fails can be reported to the user. + private let lockedInFlightCommand: Locked = Locked(nil) + /// Response code from a session start awaiting confirmation. The reply /// alone does not say whether a session began, so it is held until the /// transmitter's state settles the question. @@ -134,7 +138,7 @@ public final class G6CGMManager: CGMManager { public init(state: G6CGMManagerState) { self.lockedState = Locked(state) - lockedPendingCommands.mutate { $0 = state.pendingCommands.compactMap(Command.init(rawValue:)) } + lockedCommandQueue.mutate { $0 = CommandQueue(rawValues: state.pendingCommands) } startSession() } @@ -151,7 +155,7 @@ public final class G6CGMManager: CGMManager { } self.lockedState = Locked(state) - lockedPendingCommands.mutate { $0 = state.pendingCommands.compactMap(Command.init(rawValue:)) } + lockedCommandQueue.mutate { $0 = CommandQueue(rawValues: state.pendingCommands) } startSession() } @@ -298,28 +302,36 @@ public final class G6CGMManager: CGMManager { } /// Queues a device command for the next connection cycle. - public func enqueue(_ command: Command) { + /// + /// - Parameter notifyIfUndelivered: for commands the user is relying on + /// — a stop they believe has ended the session, a calibration they + /// believe was applied. The user is alerted if one has not gone out + /// within a few connection cycles, or fails to send, or expires unsent. + /// A failed or expired command is never retried: the user decides. + public func enqueue(_ command: Command, notifyIfUndelivered: Bool = false) { // Newest wins. Repeated trips through setup used to stack session // starts — seven went out in one connection — and because the queue // is persisted, a stale code-less start could reach the transmitter // ahead of the one carrying the sensor code the user actually typed. var superseded = 0 - lockedPendingCommands.mutate { commands in - let before = commands.count - commands.removeAll { $0.supersededBy(command) } - superseded = before - commands.count - commands.append(command) + lockedCommandQueue.mutate { queue in + superseded = queue.enqueue(command, notifyIfUndelivered: notifyIfUndelivered) } if superseded > 0 { log.default("Replaced %d queued command(s) with %{public}@", superseded, String(describing: command)) + } else { + log.default("Queued %{public}@", String(describing: command)) } mutateState { state in - state.pendingCommands.removeAll { raw in - Command(rawValue: raw).map { $0.supersededBy(command) } ?? true + state.pendingCommands = self.lockedCommandQueue.value.rawValues + + // Asking again is the user's answer to an earlier one that did + // not get through. + if let undelivered = state.undeliveredCommand, undelivered.entry.command.supersededBy(command) { + state.undeliveredCommand = nil } - state.pendingCommands.append(command.rawValue) // Reflect the entered code straight away. Waiting until the // transmitter answered meant the screen said "Not used" for as @@ -332,6 +344,27 @@ public final class G6CGMManager: CGMManager { // Nudge the connection in case it is idle. session?.start() + + #if targetEnvironment(simulator) + // No link will ever drain the queue in the simulator; apply the + // command to state directly, mirroring didComplete on hardware, + // so End Sensor / Start New Sensor work end-to-end there. + lockedCommandQueue.mutate { _ = $0.dequeue() } + mutateState { state in + state.pendingCommands = self.lockedCommandQueue.value.rawValues + switch command { + case .stopSensor: + state.sensorStartDate = nil + state.sensorCode = nil + case .startSensor(let date, let sensorCode): + state.sensorCode = sensorCode.carriesParameters ? sensorCode.code : nil + state.sensorStartDate = date + .addingTimeInterval(-(state.warmupPeriod - G6CGMManager.simulatedWarmupRemaining)) + case .calibrateSensor, .resetTransmitter: + break + } + } + #endif } public func setSensorLifeDays(_ days: Int) { @@ -383,7 +416,7 @@ public final class G6CGMManager: CGMManager { if connectionPhase == .connected { log.default("Old transmitter still connected; sending session stop before switching") session?.commandSource = self - lockedPendingCommands.mutate { $0 = [.stopSensor(at: Date())] } + lockedCommandQueue.mutate { $0 = CommandQueue([.stopSensor(at: Date())]) } } else { log.default("Old transmitter not connected; its session is left as-is") } @@ -407,7 +440,8 @@ public final class G6CGMManager: CGMManager { // middle of the state reset. The session itself is kept and pointed at // the new transmitter — the radio does not need rebuilding for a swap. session?.stop() - lockedPendingCommands.mutate { $0.removeAll() } + lockedCommandQueue.mutate { $0.removeAll() } + lockedInFlightCommand.mutate { $0 = nil } raisedAlerts = [] mutateState { state in @@ -421,6 +455,7 @@ public final class G6CGMManager: CGMManager { state.latestReading = nil state.recentReadings = [] state.pendingCommands = [] + state.undeliveredCommand = nil state.lastSessionStartFailure = nil } @@ -521,6 +556,10 @@ public final class G6CGMManager: CGMManager { // session delegate on BLE events. session?.start() + // Time-based alerts — signal loss, a command that has not got + // through — must not wait for a connection that may never come. + evaluateAlerts() + if let lastDate = state.latestReading?.date { let gap = Date().timeIntervalSince(lastDate) if gap > Self.backfillGapThreshold { @@ -542,9 +581,15 @@ public final class G6CGMManager: CGMManager { // MARK: - AlertResponder / AlertSoundVendor public func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - G6Alert(rawValue: alertIdentifier).map { alert in + let alert = G6Alert(rawValue: alertIdentifier) + alert.map { alert in log.default("Acknowledged alert: %{public}@", alert.rawValue) } + if alert == .commandFailed { + mutateState { state in + state.undeliveredCommand?.acknowledged = true + } + } completion(nil) } @@ -844,47 +889,45 @@ extension G6CGMManager: TransmitterSessionDelegate { extension G6CGMManager: TransmitterCommandSource { public func dequeuePendingCommand(for session: TransmitterSession) -> Command? { - var next: Command? - var dropped: [Command] = [] - lockedPendingCommands.mutate { commands in - // A calibration is only meaningful next to the fingerstick it came - // from; xDrip uses the same five-minute window. Sending a stale one - // would teach the sensor from a value that no longer holds. - while let candidate = commands.first { - commands.removeFirst() - if case .calibrateSensor(_, let date) = candidate, - Date().timeIntervalSince(date) > .minutes(5) { - dropped.append(candidate) - continue - } - next = candidate - break - } + var next: QueuedCommand? + var dropped: [QueuedCommand] = [] + lockedCommandQueue.mutate { queue in + (next, dropped) = queue.dequeueEntry() } + lockedInFlightCommand.mutate { $0 = next } - for command in dropped { - log.default("Dropping stale command: %{public}@", String(describing: command)) - mutateState { state in - if !state.pendingCommands.isEmpty { - state.pendingCommands.removeFirst() - } - } + for entry in dropped { + log.default("Dropping stale command: %{public}@", String(describing: entry.command)) + } + if let expired = dropped.last(where: \.notifyIfUndelivered) { + recordUndelivered(expired, reason: .expired) } - if next != nil { + if next != nil || !dropped.isEmpty { mutateState { state in - if !state.pendingCommands.isEmpty { - state.pendingCommands.removeFirst() - } + state.pendingCommands = self.lockedCommandQueue.value.rawValues } } - return next + return next?.command } public func transmitterSession(_ session: TransmitterSession, didFail command: Command, with error: Error) { log.error("Command failed: %{public}@ — %{public}@", String(describing: command), String(describing: error)) + // Not retried, whatever the command: by the next connection a stop + // or start may no longer be what the user wants, and a calibration + // would no longer match the blood it was measured from. + var inFlight: QueuedCommand? + lockedInFlightCommand.mutate { value in + inFlight = value + value = nil + } + + if let entry = inFlight, entry.command == command, entry.notifyIfUndelivered { + recordUndelivered(entry, reason: .sendFailed) + } + if case .startSensor = command { // Genuine delivery failures only; a refusal that still produced a // session is handled when the transmitter's state arrives. @@ -895,8 +938,23 @@ extension G6CGMManager: TransmitterCommandSource { } } + /// Notes a flagged command that will not reach the transmitter, so the + /// user is alerted and can decide whether to ask again. + private func recordUndelivered(_ entry: QueuedCommand, reason: UndeliveredCommand.Reason) { + log.error("Not delivered (%{public}@): %{public}@", reason.rawValue, String(describing: entry.command)) + + // A fresh failure deserves its own alert even if an earlier one is + // still showing; the host replaces it under the same identifier. + raisedAlerts.remove(.commandFailed) + + mutateState { state in + state.undeliveredCommand = UndeliveredCommand(entry: entry, reason: reason) + } + } + public func transmitterSession(_ session: TransmitterSession, didComplete command: Command, response: TransmitterRxMessage?) { log.default("Command completed: %{public}@", String(describing: command)) + lockedInFlightCommand.mutate { $0 = nil } switch command { case .startSensor(_, let sensorCode): diff --git a/G6SensorKit/G6CGMManagerState.swift b/G6SensorKit/G6CGMManagerState.swift index 40b6039..a266072 100644 --- a/G6SensorKit/G6CGMManagerState.swift +++ b/G6SensorKit/G6CGMManagerState.swift @@ -95,6 +95,10 @@ public struct G6CGMManagerState: RawRepresentable { /// calibration must not vanish if the app is relaunched meanwhile. public var pendingCommands: [Command.RawValue] + /// The last flagged command that failed to send or expired unsent. Never + /// re-sent; kept so the user is told and can act on it. + public var undeliveredCommand: UndeliveredCommand? + // MARK: - Battery /// Hundredths of a volt (310 is 3.10 V). @@ -148,6 +152,7 @@ public struct G6CGMManagerState: RawRepresentable { setupStep: G6SetupStep? = nil, pendingCommands: [Command.RawValue] = [], lastSessionStartFailure: String? = nil, + undeliveredCommand: UndeliveredCommand? = nil, algorithmStateRawValue: UInt8? = nil ) { self.transmitterID = transmitterID @@ -166,6 +171,7 @@ public struct G6CGMManagerState: RawRepresentable { self.setupStep = setupStep self.pendingCommands = pendingCommands self.lastSessionStartFailure = lastSessionStartFailure + self.undeliveredCommand = undeliveredCommand self.algorithmStateRawValue = algorithmStateRawValue } @@ -195,6 +201,7 @@ public struct G6CGMManagerState: RawRepresentable { setupStep: (rawValue["setupStep"] as? String).flatMap(G6SetupStep.init(rawValue:)), pendingCommands: rawValue["pendingCommands"] as? [Command.RawValue] ?? [], lastSessionStartFailure: rawValue["lastSessionStartFailure"] as? String, + undeliveredCommand: (rawValue["undeliveredCommand"] as? [String: Any]).flatMap(UndeliveredCommand.init(rawValue:)), algorithmStateRawValue: (rawValue["algorithmState"] as? Int).map { UInt8(clamping: $0) } ) @@ -237,6 +244,7 @@ public struct G6CGMManagerState: RawRepresentable { raw["pendingCommands"] = pendingCommands } raw["lastSessionStartFailure"] = lastSessionStartFailure + raw["undeliveredCommand"] = undeliveredCommand?.rawValue raw["algorithmState"] = algorithmStateRawValue.map { Int($0) } raw["batteryVoltageA"] = batteryVoltageA.map { Int($0) } @@ -335,6 +343,38 @@ public struct G6CGMManagerState: RawRepresentable { } } + /// The undelivered command, while it still describes something wrong. + /// A stop matters until the session ends some other way, a start until + /// one is running. A calibration has no such condition to watch, so it + /// is shown until the user has acknowledged the alert. + public var outstandingUndeliveredCommand: UndeliveredCommand? { + guard let undelivered = undeliveredCommand else { + return nil + } + switch undelivered.entry.command { + case .stopSensor: + return sensorStartDate != nil ? undelivered : nil + case .startSensor: + return hasActiveSession ? nil : undelivered + case .calibrateSensor, .resetTransmitter: + return undelivered.acknowledged ? nil : undelivered + } + } + + /// A session stop the user asked for that the transmitter has not + /// received yet. Until it does, the session keeps running — and with a + /// failing link that can be a long time. + public var pendingSessionStop: QueuedCommand? { + return pendingCommands.lazy + .compactMap(QueuedCommand.init(rawValue:)) + .first { entry in + if case .stopSensor = entry.command { + return true + } + return false + } + } + /// The device model implied by the transmitter ID prefix. G6 transmitters /// use `8…`; Dexcom ONE uses `5…` or `C…`. public var deviceModel: String { @@ -425,6 +465,7 @@ extension G6CGMManagerState: Equatable { && lhs.isOnboarded == rhs.isOnboarded && lhs.setupStep == rhs.setupStep && lhs.lastSessionStartFailure == rhs.lastSessionStartFailure + && lhs.undeliveredCommand == rhs.undeliveredCommand && lhs.algorithmStateRawValue == rhs.algorithmStateRawValue && lhs.batteryVoltageA == rhs.batteryVoltageA && lhs.batteryVoltageB == rhs.batteryVoltageB diff --git a/G6SensorKit/G6SimulatedSession.swift b/G6SensorKit/G6SimulatedSession.swift index cc886c3..9c4601d 100644 --- a/G6SensorKit/G6SimulatedSession.swift +++ b/G6SensorKit/G6SimulatedSession.swift @@ -39,11 +39,21 @@ extension G6CGMManager { let now = Date() self.mutateStateForSimulation { state in + // A relaunch resumes a session that was already running + // rather than restarting warm-up, so a seeded state survives + // long enough to be looked at. + // Transmitter part-way through its life, sensor almost done // warming up. - state.transmitterStartDate = now.addingTimeInterval(-.hours(24 * 12)) - state.sensorStartDate = now.addingTimeInterval(-(state.warmupPeriod - Self.simulatedWarmupRemaining)) - state.transmitterExpiryInDays = 90 + if state.transmitterStartDate == nil { + state.transmitterStartDate = now.addingTimeInterval(-.hours(24 * 12)) + } + if state.sensorStartDate == nil { + state.sensorStartDate = now.addingTimeInterval(-(state.warmupPeriod - Self.simulatedWarmupRemaining)) + } + // 180 days so the simulator presents as an Anubis, making the + // longer session length and expiry visible in the UI. + state.transmitterExpiryInDays = 180 state.firmwareVersion = "1.0.0.0 (simulated)" state.peripheralIdentifier = UUID() } @@ -64,6 +74,9 @@ extension G6CGMManager { } private func emitSimulatedReading() { + // No active session (ended, or not yet started): stay quiet. + guard state.sensorStartDate != nil else { return } + guard !state.isInWarmup else { // Still warming up: the state change alone refreshes the UI. notifySimulationObservers() diff --git a/G6SensorKit/Localizable.xcstrings b/G6SensorKit/Localizable.xcstrings index ff7977d..669a373 100644 --- a/G6SensorKit/Localizable.xcstrings +++ b/G6SensorKit/Localizable.xcstrings @@ -1,6 +1,18 @@ { "sourceLanguage": "en", "strings": { + "A request could not be sent to your transmitter and will not be retried. Open the CGM settings and try again.": { + "comment": "Alert body when a command failed to send", + "extractionState": "manual" + }, + "A request is still waiting for your transmitter. It is sent as soon as the transmitter connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.": { + "comment": "Alert body when a queued command has waited several connection cycles", + "extractionState": "manual" + }, + "Request not sent": { + "comment": "Alert title when a command failed to send or expired unsent", + "extractionState": "manual" + }, "Sensor expires in 24 hours": { "extractionState": "manual", "comment": "Alert title for sensor expiring in a day" @@ -33,6 +45,22 @@ "extractionState": "manual", "comment": "Alert title for an expired transmitter" }, + "Transmitter not responding": { + "comment": "Alert title when a queued command has waited several connection cycles", + "extractionState": "manual" + }, + "Your calibration was not applied: it could not be sent to your transmitter in time and will not be retried. If you still want to calibrate, take a new fingerstick and enter it.": { + "comment": "Alert body when a calibration failed to send or expired unsent", + "extractionState": "manual" + }, + "Your new sensor session has not started yet: the request is still waiting for your transmitter and is sent as soon as it connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.": { + "comment": "Alert body when a session start has waited several connection cycles", + "extractionState": "manual" + }, + "Your new sensor session was not started: the request could not be sent to your transmitter and will not be retried. Open the CGM settings and start the sensor again, with your phone close to the transmitter.": { + "comment": "Alert body when a session start failed to send", + "extractionState": "manual" + }, "Your sensor session ends in about 24 hours. Plan to have a new sensor ready.": { "extractionState": "manual", "comment": "Alert body for sensor expiring in a day" @@ -45,6 +73,14 @@ "extractionState": "manual", "comment": "Alert body for expired sensor" }, + "Your sensor session has not stopped yet: the request is still waiting for your transmitter and is sent as soon as it connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.": { + "comment": "Alert body when a session stop has waited several connection cycles", + "extractionState": "manual" + }, + "Your sensor session was not stopped: the request could not be sent to your transmitter and will not be retried. Open the CGM settings and stop the session again, with your phone close to the transmitter.": { + "comment": "Alert body when a session stop failed to send", + "extractionState": "manual" + }, "Your sensor stopped working and is not sending readings. Remove it and start a new sensor. Check your glucose with a fingerstick meter in the meantime.": { "extractionState": "manual", "comment": "Alert body for failed sensor" diff --git a/G6SensorKitUI/G6UICoordinator.swift b/G6SensorKitUI/G6UICoordinator.swift index 7c44fc5..2fa3e98 100644 --- a/G6SensorKitUI/G6UICoordinator.swift +++ b/G6SensorKitUI/G6UICoordinator.swift @@ -492,7 +492,7 @@ public class G6UICoordinator: UINavigationController, CGMManagerOnboarding, Comp } cgmManager.logSetupEvent("Queued session start with sensor code \(sensorCode.carriesParameters ? sensorCode.code : "none")") - cgmManager.enqueue(.startSensor(at: Date(), sensorCode: sensorCode)) + cgmManager.enqueue(.startSensor(at: Date(), sensorCode: sensorCode), notifyIfUndelivered: true) } private func deleteCGM() { diff --git a/G6SensorKitUI/Localizable.xcstrings b/G6SensorKitUI/Localizable.xcstrings index f218d5c..efae63b 100644 --- a/G6SensorKitUI/Localizable.xcstrings +++ b/G6SensorKitUI/Localizable.xcstrings @@ -1,838 +1,924 @@ { "sourceLanguage": "en", "strings": { - "Sensor Failed": { - "extractionState": "manual", - "comment": "Status highlight for failed sensor" + "": { }, - "Sensor Warmup": { - "extractionState": "manual", - "comment": "Status highlight during warm-up" + "—": { + "comment": "Placeholder for missing value", + "extractionState": "manual" }, - "Transmitter Expired": { - "extractionState": "manual", - "comment": "Status highlight when the transmitter is past end of life" + "“Dexcom”, “G6”, and “ONE” are trademarks of Dexcom, Inc., used here only to describe which devices are compatible. This app is not made by or endorsed by Dexcom.": { + "comment": "Trademark attribution footer", + "extractionState": "manual" }, - "No Sensor": { - "extractionState": "manual", - "comment": "Status highlight when no session is running" + "%@ remaining": { + "comment": "Session remaining (1: duration)", + "extractionState": "manual" }, - "Sensor Expired": { - "extractionState": "manual", - "comment": "Status highlight for expired sensor" + "%@ until ready": { + "comment": "Warm-up remaining (1: duration)", + "extractionState": "manual" + }, + "%@. %@": { + "comment": "A label that describes the phase and detail of a lifecycle bar.", + "isCommentAutoGenerated": true, + "localizations": { + "en": { + "stringUnit": { + "state": "new", + "value": "%1$@. %2$@" + } + } + } }, - "Calibration Needed": { - "extractionState": "manual", - "comment": "Status highlight when a calibration is requested" + "%1$@ talks to your G6 or Dexcom ONE transmitter directly. It starts and stops sensor sessions, sends your calibrations, and fills in readings it missed.": { + "comment": "Introduction: what the native driver does (1: app name)", + "extractionState": "manual" }, - "Signal Loss": { - "extractionState": "manual", - "comment": "Status highlight for signal loss" + "%1$@%2$@ per minute": { + "comment": "Trend rate (1: sign, 2: formatted rate with unit)", + "extractionState": "manual" }, - "This app": { - "extractionState": "manual", - "comment": "Fallback when the host app name cannot be read" + "%1$@%2$@/min": { + "comment": "Trend rate (1: sign, 2: formatted rate with unit)", + "extractionState": "manual" }, - "Transmitter ID": { - "extractionState": "manual", - "comment": "Navigation title for transmitter ID entry" + "%d °C": { + "comment": "Temperature in Celsius (1: degrees)", + "extractionState": "manual" }, - "Sensor Code": { - "extractionState": "manual", - "comment": "Navigation title for sensor code entry" + "%d days": { + "comment": "Number of days (1: day count)", + "extractionState": "manual" + }, + "%d min ago": { + "comment": "Minutes since last reading (1: minutes)", + "extractionState": "manual" + }, + "%d mV": { + "comment": "Voltage in millivolts (1: millivolts)", + "extractionState": "manual" + }, + "%dd %dh": { + "comment": "Duration in days and hours", + "extractionState": "manual" + }, + "%dh %dm": { + "comment": "Duration in hours and minutes", + "extractionState": "manual" + }, + "%dm": { + "comment": "Duration in minutes", + "extractionState": "manual" + }, + "%lld": { + "comment": "A number in the center of a circle.", + "isCommentAutoGenerated": true + }, + "4-digit code": { + "comment": "Sensor code text field placeholder", + "extractionState": "manual" + }, + "6-character ID": { + "comment": "Transmitter ID text field placeholder", + "extractionState": "manual" + }, + "A display-only value has been shifted by a recent calibration. It is shown for continuity and is not used for dosing.": { + "comment": "Footer explaining display-only readings", + "extractionState": "manual" + }, + "A pairing request will appear": { + "comment": "Pairing callout title", + "extractionState": "manual" + }, + "Accept the Bluetooth pairing request within about a minute. If it disappears, the app tries again on the next connection.": { + "comment": "Pairing callout body while awaiting the prompt", + "extractionState": "manual" + }, + "Active": { + "comment": "Session phase: active", + "extractionState": "manual" + }, + "Adults wear the sensor on the belly. Children aged 2 to 17 can use the belly or upper buttocks.": { + "comment": "Apply-sensor site instruction", + "extractionState": "manual" + }, + "After warm-up": { + "comment": "Why calibration is unavailable during warm-up", + "extractionState": "manual" + }, + "Allow camera access in Settings to scan the Data Matrix code on your Dexcom transmitter packaging.": { + "comment": "Camera denied alert message", + "extractionState": "manual" + }, + "Apply a new sensor": { + "comment": "Apply-sensor screen title", + "extractionState": "manual" }, "Applying Your Sensor": { - "extractionState": "manual", - "comment": "Navigation title for placement guide" + "comment": "Navigation title for placement guide", + "extractionState": "manual" }, - "Pairing": { - "extractionState": "manual", - "comment": "Navigation title for pairing screen" + "ATTACH TRANSMITTER": { + "comment": "Section label for the attach-transmitter steps", + "extractionState": "manual" }, - "Warm-Up": { - "extractionState": "manual", - "comment": "Navigation title for warm-up screen" + "Battery": { + "comment": "Transmitter details section header for battery", + "extractionState": "manual" }, - "Dexcom G6 / ONE": { - "extractionState": "manual", - "comment": "Navigation title for settings" + "Battery B is the cell that runs down first and determines whether the transmitter can finish a session.": { + "comment": "Footer explaining what battery B means", + "extractionState": "manual" + }, + "CALIBRATE": { + "comment": "Section label for the calibration steps", + "extractionState": "manual" }, "Calibration": { - "extractionState": "manual", - "comment": "Navigation title for calibration" + "comment": "Navigation title for calibration", + "extractionState": "manual" }, - "Transmitter": { - "extractionState": "manual", - "comment": "Navigation title for transmitter details" + "Calibration becomes available once warm-up finishes. Do not make insulin decisions from early readings — use a fingerstick meter and follow your care team's guidance.": { + "comment": "Footer explaining warm-up restrictions", + "extractionState": "manual" }, - "Session Length": { - "extractionState": "manual", - "comment": "Navigation title for sensor life settings" + "Calibration Needed": { + "comment": "Status highlight when a calibration is requested", + "extractionState": "manual" }, - "Dexcom Share": { - "extractionState": "manual", - "comment": "Navigation title for Share upload settings" + "Calibration requested": { + "comment": "Algorithm state: needs calibration", + "extractionState": "manual" }, - "INSERT SENSOR": { - "extractionState": "manual", - "comment": "Section label for the insert-sensor steps" + "Calibrations must be between %1$@ and %2$@. If your meter reads outside that range, treat it and follow your care team's guidance instead of calibrating.": { + "comment": "Validation message for out-of-range calibration (1: minimum with unit, 2: maximum with unit)", + "extractionState": "manual" }, - "STEP %d of 10": { - "extractionState": "manual", - "comment": "Insert-sensor step counter (1: step number)" + "Camera access is off": { + "comment": "Camera denied alert title", + "extractionState": "manual" }, - "Thoroughly wash and dry your hands.": { - "extractionState": "manual", - "comment": "Insert step 1 body" + "Cancel": { + "comment": "Cancel button", + "extractionState": "manual" + }, + "Check that the Dexcom app is not connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.": { + "comment": "Pairing troubleshooting body", + "extractionState": "manual" }, "Clean the insertion site with alcohol. Let it dry.": { - "extractionState": "manual", - "comment": "Insert step 2 body" + "comment": "Insert step 2 body", + "extractionState": "manual" }, - "The site must be completely dry or the adhesive will not stick.": { - "extractionState": "manual", - "comment": "Insert step 2 note" + "Connected to your transmitter": { + "comment": "Pairing status: connected", + "extractionState": "manual" }, - "Optional: if you use a skin adhesive, draw an empty oval on the skin and let it dry.": { - "extractionState": "manual", - "comment": "Insert step 3 body" + "Connecting to your transmitter": { + "comment": "Pairing screen heading", + "extractionState": "manual" }, - "Insert the sensor on clean skin in the centre of the oval, not on the adhesive itself.": { - "extractionState": "manual", - "comment": "Insert step 3 note" + "Continue": { + "comment": "Confirm pairing a new transmitter", + "extractionState": "manual" }, - "Take out the applicator whose sensor code you entered, and peel off its cover.": { - "extractionState": "manual", - "comment": "Insert step 4 body" + "Could not save your credentials. Try again.": { + "comment": "Status message when saving Share credentials fails", + "extractionState": "manual" }, - "Do not use it if the packaging is damaged or was already opened. Keep the packaging until the session is finished.": { - "extractionState": "manual", - "comment": "Insert step 4 note" + "Credentials saved to the Keychain.": { + "comment": "Status message after saving Share credentials", + "extractionState": "manual" }, - "Pull off both adhesive labels.": { - "extractionState": "manual", - "comment": "Insert step 5 body" + "Device": { + "comment": "Reading detail label for device", + "extractionState": "manual" }, - "Keep the tab showing the sensor code. Do not touch the adhesive.": { - "extractionState": "manual", - "comment": "Insert step 5 note" + "Dexcom Account": { + "comment": "Section header for Share account", + "extractionState": "manual" }, - "Place the applicator flat against your skin — horizontally, not upright — and press down firmly.": { - "extractionState": "manual", - "comment": "Insert step 6 body" + "Dexcom G6": { + "comment": "Introduction screen product title", + "extractionState": "manual" }, - "Fold and break off the safety guard, then throw it away.": { - "extractionState": "manual", - "comment": "Insert step 7 body" + "Dexcom G6 / ONE": { + "comment": "Navigation title for settings", + "extractionState": "manual" }, - "Push and release the button to insert the sensor.": { - "extractionState": "manual", - "comment": "Insert step 8 body" + "Dexcom Share": { + "comment": "Navigation title for Share upload settings", + "extractionState": "manual" }, - "Lift the applicator away.": { - "extractionState": "manual", - "comment": "Insert step 9 body" + "Dexcom Share Upload": { + "comment": "Row to Share upload settings", + "extractionState": "manual" }, - "Dispose of the applicator following your local rules for items that have touched blood.": { - "extractionState": "manual", - "comment": "Insert step 9 note" + "Display only": { + "comment": "Reading detail label for display-only flag", + "extractionState": "manual" }, - "Left on your skin: the sensor wire and the transmitter holder. The sensor is in.": { - "extractionState": "manual", - "comment": "Insert step 10 body" + "Dispose of the applicator following your local rules for items that have touched blood.": { + "comment": "Insert step 9 note", + "extractionState": "manual" }, - "ATTACH TRANSMITTER": { - "extractionState": "manual", - "comment": "Section label for the attach-transmitter steps" + "Do not dose on early readings": { + "comment": "Warm-up callout title", + "extractionState": "manual" }, - "STEP %d of 4": { - "extractionState": "manual", - "comment": "Attach-transmitter step counter (1: step number)" + "Do not make insulin decisions from the first readings after warm-up, or from readings that look wrong for how you feel. Use a fingerstick meter instead, and follow your care team's guidance.": { + "comment": "Warm-up callout body about dosing safety", + "extractionState": "manual" }, - "Take the transmitter out of its box and prepare it for use. Wipe the bottom with an alcohol wipe and let it dry.": { - "extractionState": "manual", - "comment": "Attach step 1 body" + "Do not round the number or reuse an older reading. If more than 5 minutes have passed, measure again.": { + "comment": "Calibration step 3 note", + "extractionState": "manual" }, "Do not touch the metal contacts and do not scratch the bottom — that can break the waterproof seal.": { - "extractionState": "manual", - "comment": "Attach step 1 note" + "comment": "Attach step 1 note", + "extractionState": "manual" }, - "Slide the transmitter tab into the slot at the narrow end of the holder.": { - "extractionState": "manual", - "comment": "Attach step 3 body" - }, - "Press the wide end of the transmitter until it clicks into the holder.": { - "extractionState": "manual", - "comment": "Attach step 4 body" + "Do not use it if the packaging is damaged or was already opened. Keep the packaging until the session is finished.": { + "comment": "Insert step 4 note", + "extractionState": "manual" }, - "Rub your fingers around the patch three times to secure it.": { - "extractionState": "manual", - "comment": "Attach step 5 body" + "Done": { + "comment": "Done button", + "extractionState": "manual" }, - "You are almost done — the sensor session starts next.": { - "extractionState": "manual", - "comment": "Attach step 5 note" + "Ending Soon": { + "comment": "Session phase: expiring soon", + "extractionState": "manual" }, - "Apply a new sensor": { - "extractionState": "manual", - "comment": "Apply-sensor screen title" + "Enter a fingerstick value": { + "comment": "Calibration screen heading", + "extractionState": "manual" }, - "Adults wear the sensor on the belly. Children aged 2 to 17 can use the belly or upper buttocks.": { - "extractionState": "manual", - "comment": "Apply-sensor site instruction" + "Enter a number.": { + "comment": "Validation message for non-numeric calibration entry", + "extractionState": "manual" }, - "Keep at least 8 cm (3 inches) away from insulin pump sites and injection sites, and avoid scars, bony areas, and your waistband.": { - "extractionState": "manual", - "comment": "Apply-sensor spacing instruction" + "Enter Calibration": { + "comment": "Button to enter a calibration", + "extractionState": "manual" }, - "HOW TO APPLY A SENSOR": { - "extractionState": "manual", - "comment": "Apply-sensor help button" + "Enter the Code": { + "comment": "Return to entering the sensor code", + "extractionState": "manual" }, - "Next": { - "extractionState": "manual", - "comment": "Next button" + "Enter the exact value your meter showed, within 5 minutes of the fingerstick.": { + "comment": "Calibration step 3 body", + "extractionState": "manual" }, - "How to apply a sensor": { - "extractionState": "manual", - "comment": "Apply-sensor help screen title" + "Enter your sensor code": { + "comment": "Sensor code screen heading", + "extractionState": "manual" }, - "Done": { - "extractionState": "manual", - "comment": "Done button" + "Enter your transmitter ID": { + "comment": "Transmitter ID screen heading", + "extractionState": "manual" }, - "CALIBRATE": { - "extractionState": "manual", - "comment": "Section label for the calibration steps" + "Expected to expire": { + "comment": "Transmitter detail label for expiry", + "extractionState": "manual" }, - "STEP %d of 3": { - "extractionState": "manual", - "comment": "Calibration step counter (1: step number)" + "Expected to finish at %1$@, about %2$d minutes from now": { + "comment": "Warm-up completion time (1: time, 2: minutes remaining)", + "extractionState": "manual" }, - "Wash and dry your hands with soap and water — not a gel cleaner.": { - "extractionState": "manual", - "comment": "Calibration step 1 body" + "Firmware": { + "comment": "Transmitter detail label for firmware", + "extractionState": "manual" }, - "Poorly washed hands are behind most inaccurate meter readings, and an inaccurate reading teaches the sensor the wrong thing.": { - "extractionState": "manual", - "comment": "Calibration step 1 note" + "First used": { + "comment": "Transmitter detail label for activation date", + "extractionState": "manual" }, - "Take a blood sample from your fingertip and measure it with your meter.": { - "extractionState": "manual", - "comment": "Calibration step 2 body" + "Fold and break off the safety guard, then throw it away.": { + "comment": "Insert step 7 body", + "extractionState": "manual" }, - "Use a fingertip only — samples from other sites are less accurate. Use the same meter throughout a sensor session, and check that your strips are in date.": { - "extractionState": "manual", - "comment": "Calibration step 2 note" + "How to apply a sensor": { + "comment": "Apply-sensor help screen title", + "extractionState": "manual" }, - "Enter the exact value your meter showed, within 5 minutes of the fingerstick.": { - "extractionState": "manual", - "comment": "Calibration step 3 body" + "How to Apply a Sensor": { + "comment": "Row to the placement guide", + "extractionState": "manual" }, - "Do not round the number or reuse an older reading. If more than 5 minutes have passed, measure again.": { - "extractionState": "manual", - "comment": "Calibration step 3 note" + "HOW TO APPLY A SENSOR": { + "comment": "Apply-sensor help button", + "extractionState": "manual" }, "How to calibrate": { - "extractionState": "manual", - "comment": "Calibration help screen title" + "comment": "Calibration help screen title", + "extractionState": "manual" }, - "Enter a fingerstick value": { - "extractionState": "manual", - "comment": "Calibration screen heading" + "HOW TO CALIBRATE": { + "comment": "Calibration help button", + "extractionState": "manual" }, - "Use a fresh fingerstick reading from your meter, taken just now. Wash and dry your hands first — a value from unwashed fingers can be badly wrong and will teach the sensor the wrong thing.": { - "extractionState": "manual", - "comment": "Calibration instructions" + "Identifier": { + "comment": "Reading detail label for sync identifier", + "extractionState": "manual" }, - "HOW TO CALIBRATE": { - "extractionState": "manual", - "comment": "Calibration help button" + "IDs that begin with 4 belong to Dexcom G5 transmitters, which this app does not support.": { + "comment": "Validation message for G5 transmitter ID", + "extractionState": "manual" + }, + "INSERT SENSOR": { + "comment": "Section label for the insert-sensor steps", + "extractionState": "manual" + }, + "Insert the sensor on clean skin in the centre of the oval, not on the adhesive itself.": { + "comment": "Insert step 3 note", + "extractionState": "manual" }, "It may take a few minutes": { - "extractionState": "manual", - "comment": "Calibration callout title" + "comment": "Calibration callout title", + "extractionState": "manual" }, - "The calibration is sent the next time your transmitter connects, which can be up to 5 minutes away. The transmitter may accept it, ask for a second value, or reject it — this screen's status will tell you which.": { - "extractionState": "manual", - "comment": "Calibration callout body about delivery timing" + "Just now": { + "comment": "Reading received less than a minute ago", + "extractionState": "manual" }, - "Send Calibration": { - "extractionState": "manual", - "comment": "Calibration submit button" + "Keep at least 8 cm (3 inches) away from insulin pump sites and injection sites, and avoid scars, bony areas, and your waistband.": { + "comment": "Apply-sensor spacing instruction", + "extractionState": "manual" }, - "Enter a number.": { - "extractionState": "manual", - "comment": "Validation message for non-numeric calibration entry" + "Keep the tab showing the sensor code. Do not touch the adhesive.": { + "comment": "Insert step 5 note", + "extractionState": "manual" }, - "Pair New Transmitter": { - "extractionState": "manual", - "comment": "Row to pair a different transmitter" + "Last checked": { + "comment": "Transmitter detail label for last battery read", + "extractionState": "manual" }, - "Pair a new transmitter?": { - "extractionState": "manual", - "comment": "Confirmation title for pairing a new transmitter" + "Last Reading": { + "comment": "Settings section: last reading", + "extractionState": "manual" }, - "Continue": { - "extractionState": "manual", - "comment": "Confirm pairing a new transmitter" + "Latest Reading": { + "comment": "Reading detail screen title", + "extractionState": "manual" }, - "Your current sensor session will end. A session lives on the transmitter, so it cannot move to a new one — you will start a fresh session after pairing, and can reuse the sensor you are wearing. Stop the sensor first if your old transmitter is still attached and you want it stopped cleanly. Your other settings are kept.": { - "extractionState": "manual", - "comment": "Confirmation message for pairing a new transmitter while a session is running" + "Left on your skin: the sensor wire and the transmitter holder. The sensor is in.": { + "comment": "Insert step 10 body", + "extractionState": "manual" }, - "You will enter the new transmitter's ID and can then start a sensor session on it. Your other settings are kept.": { - "extractionState": "manual", - "comment": "Confirmation message for pairing a new transmitter with no session running" + "Lifetime": { + "comment": "Transmitter details section header for lifetime", + "extractionState": "manual" }, - "Model": { - "extractionState": "manual", - "comment": "Transmitter detail label for model" + "Lift the applicator away.": { + "comment": "Insert step 9 body", + "extractionState": "manual" }, - "Firmware": { - "extractionState": "manual", - "comment": "Transmitter detail label for firmware" + "Looking for your transmitter…": { + "comment": "Pairing status: searching", + "extractionState": "manual" }, - "Unknown": { - "extractionState": "manual", - "comment": "Placeholder for unknown value" + "Model": { + "comment": "Transmitter detail label for model", + "extractionState": "manual" }, - "First used": { - "extractionState": "manual", - "comment": "Transmitter detail label for activation date" + "Modified transmitters can keep a sensor running past the standard 10 days. This setting only changes when this app treats the session as finished; it does not change the sensor's accuracy. Sensor readings can drift the longer a sensor is worn, so check against a fingerstick meter if a reading does not match how you feel.": { + "comment": "Footer explaining extended session length and its accuracy caveat", + "extractionState": "manual" }, - "Expected to expire": { - "extractionState": "manual", - "comment": "Transmitter detail label for expiry" + "Next": { + "comment": "Next button", + "extractionState": "manual" }, - "Reported lifetime": { - "extractionState": "manual", - "comment": "Transmitter detail label for reported lifetime" + "No": { + "comment": "Negative", + "extractionState": "manual" }, - "%d days": { - "extractionState": "manual", - "comment": "Number of days (1: day count)" + "No code on your sensor?": { + "comment": "Sensor code callout title", + "extractionState": "manual" }, - "Lifetime": { - "extractionState": "manual", - "comment": "Transmitter details section header for lifetime" + "No readings — replace the sensor.": { + "comment": "Placeholder when the sensor has failed", + "extractionState": "manual" }, - "This transmitter is past the end of its life. It still reports readings for a running session, but cannot start a new sensor.": { - "extractionState": "manual", - "comment": "Footer shown when the transmitter has expired" + "No sensor": { + "comment": "Why calibration is unavailable without a session", + "extractionState": "manual" }, - "This transmitter reports an extended 180-day lifetime, which means it has been modified. Warm-up is shorter and you can choose how long sessions run.": { - "extractionState": "manual", - "comment": "Footer explaining Anubis detection" + "No Sensor": { + "comment": "Status highlight when no session is running", + "extractionState": "manual" }, - "Session length: %d days": { - "extractionState": "manual", - "comment": "Session length value (1: day count)" + "No Signal": { + "comment": "Session phase: signal loss", + "extractionState": "manual" }, - "Modified transmitters can keep a sensor running past the standard 10 days. This setting only changes when this app treats the session as finished; it does not change the sensor's accuracy. Sensor readings can drift the longer a sensor is worn, so check against a fingerstick meter if a reading does not match how you feel.": { - "extractionState": "manual", - "comment": "Footer explaining extended session length and its accuracy caveat" + "Not available": { + "comment": "Value when the transmitter reports no trend", + "extractionState": "manual" }, - "Upload to Dexcom Share": { - "extractionState": "manual", - "comment": "Toggle label for Share upload" + "Not used": { + "comment": "Value when a session runs without a sensor code", + "extractionState": "manual" }, - "Turning this on sends your readings to Dexcom Share so people who follow you can see them in the Dexcom Follow app. It is optional, and your readings still work in this app without it.": { - "extractionState": "manual", - "comment": "Footer explaining Share upload" + "OK": { + "comment": "Algorithm state: ok", + "extractionState": "manual" }, - "Username": { - "extractionState": "manual", - "comment": "Share account username field" + "On the transmitter box, next to the barcode.": { + "comment": "Transmitter ID: location on the box", + "extractionState": "manual" }, - "Password": { - "extractionState": "manual", - "comment": "Share account password field" + "Open Settings": { + "comment": "Open Settings button", + "extractionState": "manual" }, - "Save Credentials": { - "extractionState": "manual", - "comment": "Button to save Share credentials" + "Optional: if you use a skin adhesive, draw an empty oval on the skin and let it dry.": { + "comment": "Insert step 3 body", + "extractionState": "manual" }, - "Dexcom Account": { - "extractionState": "manual", - "comment": "Section header for Share account" + "Or on the back of the transmitter itself.": { + "comment": "Transmitter ID: location on the transmitter", + "extractionState": "manual" }, - "Your username and password are stored in the iOS Keychain on this device only. They are never written into app settings, logs, or backups of this app's data.": { - "extractionState": "manual", - "comment": "Footer explaining Keychain credential storage" + "Pair a new transmitter?": { + "comment": "Confirmation title for pairing a new transmitter", + "extractionState": "manual" }, - "Upload turned off and saved credentials removed.": { - "extractionState": "manual", - "comment": "Status message after disabling Share upload" + "Pair New Transmitter": { + "comment": "Row to pair a different transmitter", + "extractionState": "manual" }, - "Credentials saved to the Keychain.": { - "extractionState": "manual", - "comment": "Status message after saving Share credentials" + "Pairing": { + "comment": "Navigation title for pairing screen", + "extractionState": "manual" }, - "Could not save your credentials. Try again.": { - "extractionState": "manual", - "comment": "Status message when saving Share credentials fails" + "Password": { + "comment": "Share account password field", + "extractionState": "manual" }, - "Warming Up": { - "extractionState": "manual", - "comment": "Session phase: warm-up" + "Place the applicator flat against your skin — horizontally, not upright — and press down firmly.": { + "comment": "Insert step 6 body", + "extractionState": "manual" }, - "Active": { - "extractionState": "manual", - "comment": "Session phase: active" + "Poorly washed hands are behind most inaccurate meter readings, and an inaccurate reading teaches the sensor the wrong thing.": { + "comment": "Calibration step 1 note", + "extractionState": "manual" }, - "Ending Soon": { - "extractionState": "manual", - "comment": "Session phase: expiring soon" + "Press the wide end of the transmitter until it clicks into the holder.": { + "comment": "Attach step 4 body", + "extractionState": "manual" }, - "Session Ended": { - "extractionState": "manual", - "comment": "Session phase: expired" + "Pull off both adhesive labels.": { + "comment": "Insert step 5 body", + "extractionState": "manual" }, - "No Signal": { - "extractionState": "manual", - "comment": "Session phase: signal loss" + "Push and release the button to insert the sensor.": { + "comment": "Insert step 8 body", + "extractionState": "manual" }, - "Dexcom G6": { - "extractionState": "manual", - "comment": "Introduction screen product title" + "Reading": { + "comment": "Reading detail section header", + "extractionState": "manual" }, - "%1$@ talks to your G6 or Dexcom ONE transmitter directly. It starts and stops sensor sessions, sends your calibrations, and fills in readings it missed.": { - "extractionState": "manual", - "comment": "Introduction: what the native driver does (1: app name)" + "Readings will appear as they arrive from the transmitter, about every 5 minutes.": { + "comment": "Explanation shown once warm-up has completed", + "extractionState": "manual" }, - "You do not need the Dexcom app — and you must not let it use the same transmitter at the same time.": { - "extractionState": "manual", - "comment": "Introduction: single-app requirement" + "Remove CGM": { + "comment": "Confirm CGM removal", + "extractionState": "manual" }, - "Enter your transmitter ID": { - "extractionState": "manual", - "comment": "Transmitter ID screen heading" + "Remove CGM from App": { + "comment": "Button to delete the CGM manager", + "extractionState": "manual" }, - "On the transmitter box, next to the barcode.": { - "extractionState": "manual", - "comment": "Transmitter ID: location on the box" + "Remove this CGM?": { + "comment": "Confirmation title for removing the CGM", + "extractionState": "manual" }, - "Or on the back of the transmitter itself.": { - "extractionState": "manual", - "comment": "Transmitter ID: location on the transmitter" + "Replace sensor": { + "comment": "Detail when the session is over", + "extractionState": "manual" }, - "6-character ID": { - "extractionState": "manual", - "comment": "Transmitter ID text field placeholder" + "Reported lifetime": { + "comment": "Transmitter detail label for reported lifetime", + "extractionState": "manual" }, - "This app works with Dexcom G6 and Dexcom ONE transmitters. Older G5 transmitters, whose IDs begin with 4, are not supported.": { - "extractionState": "manual", - "comment": "Transmitter ID: supported hardware note" + "Resistance": { + "comment": "Transmitter detail label for battery resistance", + "extractionState": "manual" }, - "IDs that begin with 4 belong to Dexcom G5 transmitters, which this app does not support.": { - "extractionState": "manual", - "comment": "Validation message for G5 transmitter ID" + "Rub your fingers around the patch three times to secure it.": { + "comment": "Attach step 5 body", + "extractionState": "manual" }, - "Enter your sensor code": { - "extractionState": "manual", - "comment": "Sensor code screen heading" + "Save Credentials": { + "comment": "Button to save Share credentials", + "extractionState": "manual" }, - "The 4-digit code is on the applicator’s adhesive label, and is unique to that sensor. Use the code from the applicator you are about to insert — a code from a different one will make readings inaccurate.": { - "extractionState": "manual", - "comment": "Sensor code: where to find it" + "Scan package code": { + "comment": "Scan package barcode button", + "extractionState": "manual" }, - "4-digit code": { - "extractionState": "manual", - "comment": "Sensor code text field placeholder" + "Scanned a Dexcom package, but couldn't find a transmitter ID in it. Type the ID manually.": { + "comment": "Scanned Dexcom barcode without a transmitter ID", + "extractionState": "manual" }, - "No code on your sensor?": { - "extractionState": "manual", - "comment": "Sensor code callout title" + "Send Calibration": { + "comment": "Calibration submit button", + "extractionState": "manual" }, - "You can start without one. The transmitter will then ask you for two fingerstick glucose values after warm-up, and for one more each day. \"Calibration\" means telling the sensor what your meter reads, so it can correct itself.": { - "extractionState": "manual", - "comment": "Sensor code callout body explaining calibration" + "Sensor": { + "comment": "Settings section: sensor", + "extractionState": "manual" }, - "Use This Code": { - "extractionState": "manual", - "comment": "Sensor code submit button" + "Sensor code": { + "comment": "Metadata label for sensor code", + "extractionState": "manual" }, - "Start Without a Code": { - "extractionState": "manual", - "comment": "Button to start a session with no sensor code" + "Sensor Code": { + "comment": "Navigation title for sensor code entry", + "extractionState": "manual" }, - "Start without a sensor code?": { - "extractionState": "manual", - "comment": "Confirmation title for a code-less session" + "Sensor Expired": { + "comment": "Status highlight for expired sensor", + "extractionState": "manual" }, - "Enter the Code": { - "extractionState": "manual", - "comment": "Return to entering the sensor code" + "Sensor failed": { + "comment": "Algorithm state: failed", + "extractionState": "manual" }, - "Without the code the sensor cannot use its factory calibration, and the transmitter will ask you for two fingerstick values after warm-up and one every day after that. The code is on the applicator label.": { - "extractionState": "manual", - "comment": "Confirmation message explaining a code-less session" + "Sensor Failed": { + "comment": "Status highlight for failed sensor", + "extractionState": "manual" + }, + "Sensor session found": { + "comment": "Pairing success label for an existing session", + "extractionState": "manual" }, - "That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.": { - "extractionState": "manual", - "comment": "Validation message for unrecognized sensor code" + "Sensor start queued — it is sent the next time the transmitter connects, which can take up to 5 minutes.": { + "comment": "Shown while a session start is waiting for the next connection", + "extractionState": "manual" }, - "Connecting to your transmitter": { - "extractionState": "manual", - "comment": "Pairing screen heading" + "Sensor started": { + "comment": "Metadata label for sensor start", + "extractionState": "manual" }, - "Your transmitter only talks to your phone about once every 5 minutes, so this can take a few minutes. Keep your phone nearby.": { - "extractionState": "manual", - "comment": "Pairing: explanation of the 5-minute cycle" + "Sensor status": { + "comment": "Reading detail label for algorithm state", + "extractionState": "manual" }, - "Tap Pair on the iOS prompt": { - "extractionState": "manual", - "comment": "Pairing callout title while awaiting the prompt" + "Sensor Warmup": { + "comment": "Status highlight during warm-up", + "extractionState": "manual" }, - "Accept the Bluetooth pairing request within about a minute. If it disappears, the app tries again on the next connection.": { - "extractionState": "manual", - "comment": "Pairing callout body while awaiting the prompt" + "Session Ended": { + "comment": "Session phase: expired", + "extractionState": "manual" }, - "A pairing request will appear": { - "extractionState": "manual", - "comment": "Pairing callout title" + "Session ends": { + "comment": "Metadata label for session end", + "extractionState": "manual" }, - "The first time they connect, iOS shows a Bluetooth pairing request. Tap Pair within about a minute — if it disappears, the app will try again on the next connection.": { - "extractionState": "manual", - "comment": "Pairing callout body about the iOS prompt" + "Session Length": { + "comment": "Navigation title for sensor life settings", + "extractionState": "manual" }, - "Sensor session found": { - "extractionState": "manual", - "comment": "Pairing success label for an existing session" + "Session length: %d days": { + "comment": "Session length value (1: day count)", + "extractionState": "manual" }, - "Still looking": { - "extractionState": "manual", - "comment": "Pairing troubleshooting title" + "Session stop queued — it is sent the next time the transmitter connects, which can take up to 5 minutes. Readings continue until then.": { + "comment": "Shown while a session stop is waiting for the next connection", + "extractionState": "manual" }, - "Check that the Dexcom app is not connected to this transmitter, that the transmitter ID is exactly right, and that your phone is next to it. Bluetooth must be on.": { - "extractionState": "manual", - "comment": "Pairing troubleshooting body" + "Session stopped": { + "comment": "Algorithm state: stopped", + "extractionState": "manual" }, "Share Logs": { - "extractionState": "manual", - "comment": "Row to export the log files" - }, - "Connected to your transmitter": { - "extractionState": "manual", - "comment": "Pairing status: connected" + "comment": "Row to export the log files", + "extractionState": "manual" }, - "Waiting for you to accept pairing…": { - "extractionState": "manual", - "comment": "Pairing status: awaiting prompt" + "Signal Loss": { + "comment": "Status highlight for signal loss", + "extractionState": "manual" }, - "Looking for your transmitter…": { - "extractionState": "manual", - "comment": "Pairing status: searching" + "Slide the transmitter tab into the slot at the narrow end of the holder.": { + "comment": "Attach step 3 body", + "extractionState": "manual" }, - "Your sensor is warming up": { - "extractionState": "manual", - "comment": "Warm-up screen heading" + "Source": { + "comment": "Reading detail section header for source", + "extractionState": "manual" }, - "Warm-up takes about %d minutes. You will not get any glucose readings until it finishes.": { - "extractionState": "manual", - "comment": "Warm-up duration explanation (1: minutes)" + "Start New Sensor": { + "comment": "Button to begin a new sensor session", + "extractionState": "manual" }, - "Do not dose on early readings": { - "extractionState": "manual", - "comment": "Warm-up callout title" + "Start time unknown": { + "comment": "Detail when a session is running but its start is unknown", + "extractionState": "manual" }, - "Do not make insulin decisions from the first readings after warm-up, or from readings that look wrong for how you feel. Use a fingerstick meter instead, and follow your care team's guidance.": { - "extractionState": "manual", - "comment": "Warm-up callout body about dosing safety" + "Start Without a Code": { + "comment": "Button to start a session with no sensor code", + "extractionState": "manual" }, - "Trend": { - "extractionState": "manual", - "comment": "Reading detail label for trend" + "Start without a sensor code?": { + "comment": "Confirmation title for a code-less session", + "extractionState": "manual" }, - "Sensor status": { - "extractionState": "manual", - "comment": "Reading detail label for algorithm state" + "STEP %d of 3": { + "comment": "Calibration step counter (1: step number)", + "extractionState": "manual" }, - "Display only": { - "extractionState": "manual", - "comment": "Reading detail label for display-only flag" + "STEP %d of 4": { + "comment": "Attach-transmitter step counter (1: step number)", + "extractionState": "manual" }, - "Yes": { - "extractionState": "manual", - "comment": "Affirmative" + "STEP %d of 10": { + "comment": "Insert-sensor step counter (1: step number)", + "extractionState": "manual" }, - "No": { - "extractionState": "manual", - "comment": "Negative" + "Still looking": { + "comment": "Pairing troubleshooting title", + "extractionState": "manual" }, - "Reading": { - "extractionState": "manual", - "comment": "Reading detail section header" + "Stop Sensor Session": { + "comment": "Button to stop the sensor session", + "extractionState": "manual" }, - "A display-only value has been shifted by a recent calibration. It is shown for continuity and is not used for dosing.": { - "extractionState": "manual", - "comment": "Footer explaining display-only readings" + "Stop Session": { + "comment": "Confirm stop session", + "extractionState": "manual" }, - "Device": { - "extractionState": "manual", - "comment": "Reading detail label for device" + "Stop this sensor session?": { + "comment": "Confirmation title for stopping a session", + "extractionState": "manual" }, - "Identifier": { - "extractionState": "manual", - "comment": "Reading detail label for sync identifier" + "Stopping Sensor Session…": { + "comment": "Disabled row shown while a session stop waits for the transmitter", + "extractionState": "manual" }, - "Source": { - "extractionState": "manual", - "comment": "Reading detail section header for source" + "Take a blood sample from your fingertip and measure it with your meter.": { + "comment": "Calibration step 2 body", + "extractionState": "manual" }, - "The identifier is derived from the transmitter's own clock, so the same reading keeps one identity whether it arrives live or is filled in later.": { - "extractionState": "manual", - "comment": "Footer explaining the sync identifier" + "Take out the applicator whose sensor code you entered, and peel off its cover.": { + "comment": "Insert step 4 body", + "extractionState": "manual" }, - "Latest Reading": { - "extractionState": "manual", - "comment": "Reading detail screen title" + "Take the transmitter out of its box and prepare it for use. Wipe the bottom with an alcohol wipe and let it dry.": { + "comment": "Attach step 1 body", + "extractionState": "manual" }, - "Not available": { - "extractionState": "manual", - "comment": "Value when the transmitter reports no trend" + "Tap Pair on the iOS prompt": { + "comment": "Pairing callout title while awaiting the prompt", + "extractionState": "manual" }, - "Sensor failed": { - "extractionState": "manual", - "comment": "Algorithm state: failed" + "Temperature": { + "comment": "Transmitter detail label for temperature", + "extractionState": "manual" }, - "Warming up": { - "extractionState": "manual", - "comment": "Algorithm state: warm-up" + "That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.": { + "comment": "Validation message for unrecognized sensor code", + "extractionState": "manual" }, - "Session stopped": { - "extractionState": "manual", - "comment": "Algorithm state: stopped" + "That doesn't look like a Dexcom package. Try again or type the ID manually.": { + "comment": "Scanned non-Dexcom barcode", + "extractionState": "manual" }, - "Calibration requested": { - "extractionState": "manual", - "comment": "Algorithm state: needs calibration" + "The 4-digit code is on the applicator’s adhesive label, and is unique to that sensor. Use the code from the applicator you are about to insert — a code from a different one will make readings inaccurate.": { + "comment": "Sensor code: where to find it", + "extractionState": "manual" }, - "OK": { - "extractionState": "manual", - "comment": "Algorithm state: ok" + "The calibration is sent the next time your transmitter connects, which can be up to 5 minutes away. The transmitter may accept it, ask for a second value, or reject it — this screen's status will tell you which.": { + "comment": "Calibration callout body about delivery timing", + "extractionState": "manual" }, - "Unusable (code %d)": { - "extractionState": "manual", - "comment": "Algorithm state: unusable with raw code" + "The camera couldn't start. Try again or type the ID manually.": { + "comment": "Scanner failed to start", + "extractionState": "manual" }, - "Start time unknown": { - "extractionState": "manual", - "comment": "Detail when a session is running but its start is unknown" + "The first time they connect, iOS shows a Bluetooth pairing request. Tap Pair within about a minute — if it disappears, the app will try again on the next connection.": { + "comment": "Pairing callout body about the iOS prompt", + "extractionState": "manual" }, - "%@ until ready": { - "extractionState": "manual", - "comment": "Warm-up remaining (1: duration)" + "The identifier is derived from the transmitter's own clock, so the same reading keeps one identity whether it arrives live or is filled in later.": { + "comment": "Footer explaining the sync identifier", + "extractionState": "manual" }, - "Replace sensor": { - "extractionState": "manual", - "comment": "Detail when the session is over" + "The site must be completely dry or the adhesive will not stick.": { + "comment": "Insert step 2 note", + "extractionState": "manual" }, - "%@ remaining": { - "extractionState": "manual", - "comment": "Session remaining (1: duration)" + "The stop could not be sent to the transmitter, so the session is still running. Stop it again with your phone close to the transmitter.": { + "comment": "Shown when a session stop failed to send", + "extractionState": "manual" }, - "—": { - "extractionState": "manual", - "comment": "Placeholder for missing value" + "The stop is still waiting for the transmitter, so the session is still running. It is sent as soon as the transmitter connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.": { + "comment": "Shown while a session stop has waited several connection cycles", + "extractionState": "manual" }, - "Just now": { - "extractionState": "manual", - "comment": "Reading received less than a minute ago" + "This app": { + "comment": "Fallback when the host app name cannot be read", + "extractionState": "manual" }, - "%d min ago": { - "extractionState": "manual", - "comment": "Minutes since last reading (1: minutes)" + "This app will stop reading from your transmitter. Your sensor session keeps running on the transmitter itself.": { + "comment": "Confirmation message for removing the CGM", + "extractionState": "manual" }, - "%dd %dh": { - "extractionState": "manual", - "comment": "Duration in days and hours" + "This app works with Dexcom G6 and Dexcom ONE transmitters. Older G5 transmitters, whose IDs begin with 4, are not supported.": { + "comment": "Transmitter ID: supported hardware note", + "extractionState": "manual" }, - "%dh %dm": { - "extractionState": "manual", - "comment": "Duration in hours and minutes" + "This transmitter is past the end of its life. It still reports readings for a running session, but cannot start a new sensor.": { + "comment": "Footer shown when the transmitter has expired", + "extractionState": "manual" }, - "%dm": { - "extractionState": "manual", - "comment": "Duration in minutes" + "This transmitter reports an extended 180-day lifetime, which means it has been modified. Warm-up is shorter and you can choose how long sessions run.": { + "comment": "Footer explaining Anubis detection", + "extractionState": "manual" }, - "Sensor start queued — it is sent the next time the transmitter connects, which can take up to 5 minutes.": { - "extractionState": "manual", - "comment": "Shown while a session start is waiting for the next connection" + "Thoroughly wash and dry your hands.": { + "comment": "Insert step 1 body", + "extractionState": "manual" }, - "Sensor": { - "extractionState": "manual", - "comment": "Settings section: sensor" + "Transmitter": { + "comment": "Navigation title for transmitter details", + "extractionState": "manual" }, - "Last Reading": { - "extractionState": "manual", - "comment": "Settings section: last reading" + "Transmitter Details": { + "comment": "Row to transmitter details", + "extractionState": "manual" }, - "No readings — replace the sensor.": { - "extractionState": "manual", - "comment": "Placeholder when the sensor has failed" + "Transmitter Expired": { + "comment": "Status highlight when the transmitter is past end of life", + "extractionState": "manual" }, - "Warming up — readings start soon.": { - "extractionState": "manual", - "comment": "Placeholder during warm-up" + "Transmitter ID": { + "comment": "Navigation title for transmitter ID entry", + "extractionState": "manual" }, - "Waiting for first reading…": { - "extractionState": "manual", - "comment": "Placeholder before the first reading" + "Trend": { + "comment": "Reading detail label for trend", + "extractionState": "manual" }, - "Sensor started": { - "extractionState": "manual", - "comment": "Metadata label for sensor start" + "Turning this on sends your readings to Dexcom Share so people who follow you can see them in the Dexcom Follow app. It is optional, and your readings still work in this app without it.": { + "comment": "Footer explaining Share upload", + "extractionState": "manual" }, - "Session ends": { - "extractionState": "manual", - "comment": "Metadata label for session end" + "Unknown": { + "comment": "Placeholder for unknown value", + "extractionState": "manual" }, - "Sensor code": { - "extractionState": "manual", - "comment": "Metadata label for sensor code" + "Unusable (code %d)": { + "comment": "Algorithm state: unusable with raw code", + "extractionState": "manual" }, - "Not used": { - "extractionState": "manual", - "comment": "Value when a session runs without a sensor code" + "Upload to Dexcom Share": { + "comment": "Toggle label for Share upload", + "extractionState": "manual" }, - "Start New Sensor": { - "extractionState": "manual", - "comment": "Button to begin a new sensor session" + "Upload turned off and saved credentials removed.": { + "comment": "Status message after disabling Share upload", + "extractionState": "manual" }, - "Enter Calibration": { - "extractionState": "manual", - "comment": "Button to enter a calibration" + "Use a fingertip only — samples from other sites are less accurate. Use the same meter throughout a sensor session, and check that your strips are in date.": { + "comment": "Calibration step 2 note", + "extractionState": "manual" }, - "After warm-up": { - "extractionState": "manual", - "comment": "Why calibration is unavailable during warm-up" + "Use a fresh fingerstick reading from your meter, taken just now. Wash and dry your hands first — a value from unwashed fingers can be badly wrong and will teach the sensor the wrong thing.": { + "comment": "Calibration instructions", + "extractionState": "manual" }, - "No sensor": { - "extractionState": "manual", - "comment": "Why calibration is unavailable without a session" + "Use This Code": { + "comment": "Sensor code submit button", + "extractionState": "manual" }, - "Stop Sensor Session": { - "extractionState": "manual", - "comment": "Button to stop the sensor session" + "Username": { + "comment": "Share account username field", + "extractionState": "manual" }, - "Stop this sensor session?": { - "extractionState": "manual", - "comment": "Confirmation title for stopping a session" + "Voltage A": { + "comment": "Transmitter detail label for battery A", + "extractionState": "manual" }, - "Stop Session": { - "extractionState": "manual", - "comment": "Confirm stop session" + "Voltage B": { + "comment": "Transmitter detail label for battery B", + "extractionState": "manual" }, - "You will stop getting readings until you start a new sensor. A stopped session cannot be restarted.": { - "extractionState": "manual", - "comment": "Confirmation message for stopping a session" + "Voltage B is getting low. The transmitter should finish the sensor you are wearing, but order a replacement now.": { + "comment": "Footer shown when battery B is low", + "extractionState": "manual" }, - "Calibration becomes available once warm-up finishes. Do not make insulin decisions from early readings — use a fingerstick meter and follow your care team's guidance.": { - "extractionState": "manual", - "comment": "Footer explaining warm-up restrictions" + "Voltage B is very low. This transmitter may stop sending readings at any time, even during a session. Replace it as soon as you can.": { + "comment": "Footer shown when battery B is very low", + "extractionState": "manual" }, - "Transmitter Details": { - "extractionState": "manual", - "comment": "Row to transmitter details" + "Waiting for first reading…": { + "comment": "Placeholder before the first reading", + "extractionState": "manual" }, - "Dexcom Share Upload": { - "extractionState": "manual", - "comment": "Row to Share upload settings" + "Waiting for you to accept pairing…": { + "comment": "Pairing status: awaiting prompt", + "extractionState": "manual" }, - "How to Apply a Sensor": { - "extractionState": "manual", - "comment": "Row to the placement guide" + "Warm-Up": { + "comment": "Navigation title for warm-up screen", + "extractionState": "manual" }, - "Remove CGM from App": { - "extractionState": "manual", - "comment": "Button to delete the CGM manager" + "Warm-up is finished": { + "comment": "Warm-up screen heading once warm-up has completed", + "extractionState": "manual" }, - "Remove this CGM?": { - "extractionState": "manual", - "comment": "Confirmation title for removing the CGM" + "Warm-up takes about %d minutes. You will not get any glucose readings until it finishes.": { + "comment": "Warm-up duration explanation (1: minutes)", + "extractionState": "manual" }, - "Remove CGM": { - "extractionState": "manual", - "comment": "Confirm CGM removal" + "Warming up": { + "comment": "Algorithm state: warm-up", + "extractionState": "manual" }, - "This app will stop reading from your transmitter. Your sensor session keeps running on the transmitter itself.": { - "extractionState": "manual", - "comment": "Confirmation message for removing the CGM" + "Warming Up": { + "comment": "Session phase: warm-up", + "extractionState": "manual" }, - "“Dexcom”, “G6”, and “ONE” are trademarks of Dexcom, Inc., used here only to describe which devices are compatible. This app is not made by or endorsed by Dexcom.": { - "extractionState": "manual", - "comment": "Trademark attribution footer" + "Warming up — readings start soon.": { + "comment": "Placeholder during warm-up", + "extractionState": "manual" }, - "Warm-up is finished": { - "extractionState": "manual", - "comment": "Warm-up screen heading once warm-up has completed" + "Wash and dry your hands with soap and water — not a gel cleaner.": { + "comment": "Calibration step 1 body", + "extractionState": "manual" }, - "Readings will appear as they arrive from the transmitter, about every 5 minutes.": { - "extractionState": "manual", - "comment": "Explanation shown once warm-up has completed" + "Without the code the sensor cannot use its factory calibration, and the transmitter will ask you for two fingerstick values after warm-up and one every day after that. The code is on the applicator label.": { + "comment": "Confirmation message explaining a code-less session", + "extractionState": "manual" }, - "Expected to finish at %1$@, about %2$d minutes from now": { - "extractionState": "manual", - "comment": "Warm-up completion time (1: time, 2: minutes remaining)" + "Yes": { + "comment": "Affirmative", + "extractionState": "manual" }, - "Calibrations must be between %1$@ and %2$@. If your meter reads outside that range, treat it and follow your care team's guidance instead of calibrating.": { - "extractionState": "manual", - "comment": "Validation message for out-of-range calibration (1: minimum with unit, 2: maximum with unit)" + "You are almost done — the sensor session starts next.": { + "comment": "Attach step 5 note", + "extractionState": "manual" }, - "Voltage A": { - "extractionState": "manual", - "comment": "Transmitter detail label for battery A" + "You can start without one. The transmitter will then ask you for two fingerstick glucose values after warm-up, and for one more each day. \"Calibration\" means telling the sensor what your meter reads, so it can correct itself.": { + "comment": "Sensor code callout body explaining calibration", + "extractionState": "manual" }, - "%d mV": { - "extractionState": "manual", - "comment": "Voltage in millivolts (1: millivolts)" + "You do not need the Dexcom app — and you must not let it use the same transmitter at the same time.": { + "comment": "Introduction: single-app requirement", + "extractionState": "manual" }, - "Voltage B": { - "extractionState": "manual", - "comment": "Transmitter detail label for battery B" + "You will enter the new transmitter's ID and can then start a sensor session on it. Your other settings are kept.": { + "comment": "Confirmation message for pairing a new transmitter with no session running", + "extractionState": "manual" }, - "Resistance": { - "extractionState": "manual", - "comment": "Transmitter detail label for battery resistance" + "You will stop getting readings until you start a new sensor. A stopped session cannot be restarted.": { + "comment": "Confirmation message for stopping a session", + "extractionState": "manual" }, - "Temperature": { - "extractionState": "manual", - "comment": "Transmitter detail label for temperature" + "Your current sensor session will end. A session lives on the transmitter, so it cannot move to a new one — you will start a fresh session after pairing, and can reuse the sensor you are wearing. Stop the sensor first if your old transmitter is still attached and you want it stopped cleanly. Your other settings are kept.": { + "comment": "Confirmation message for pairing a new transmitter while a session is running", + "extractionState": "manual" }, - "%d °C": { - "extractionState": "manual", - "comment": "Temperature in Celsius (1: degrees)" + "Your last calibration could not be sent to the transmitter and was not applied. To calibrate, take a new fingerstick and enter it.": { + "comment": "Shown when a calibration failed to send or expired unsent", + "extractionState": "manual" }, - "Last checked": { - "extractionState": "manual", - "comment": "Transmitter detail label for last battery read" + "Your sensor is warming up": { + "comment": "Warm-up screen heading", + "extractionState": "manual" }, - "Battery": { - "extractionState": "manual", - "comment": "Transmitter details section header for battery" + "Your transmitter only talks to your phone about once every 5 minutes, so this can take a few minutes. Keep your phone nearby.": { + "comment": "Pairing: explanation of the 5-minute cycle", + "extractionState": "manual" }, - "Voltage B is very low. This transmitter may stop sending readings at any time, even during a session. Replace it as soon as you can.": { - "extractionState": "manual", - "comment": "Footer shown when battery B is very low" + "Your username and password are stored in the iOS Keychain on this device only. They are never written into app settings, logs, or backups of this app's data.": { + "comment": "Footer explaining Keychain credential storage", + "extractionState": "manual" }, - "Voltage B is getting low. The transmitter should finish the sensor you are wearing, but order a replacement now.": { + "Scan applicator code": { "extractionState": "manual", - "comment": "Footer shown when battery B is low" + "comment": "Scan applicator barcode button" }, - "Battery B is the cell that runs down first and determines whether the transmitter can finish a session.": { + "Allow camera access in Settings to scan the Data Matrix code on your sensor applicator.": { "extractionState": "manual", - "comment": "Footer explaining what battery B means" + "comment": "Camera denied alert message (sensor code)" }, - "%1$@%2$@ per minute": { + "Couldn't find a sensor code in that barcode. The code is in the Data Matrix on the applicator, not the sensor box.": { "extractionState": "manual", - "comment": "Trend rate (1: sign, 2: formatted rate with unit)" + "comment": "Scanned barcode without a sensor code" }, - "%1$@%2$@/min": { + "The camera couldn't start. Try again or type the code manually.": { "extractionState": "manual", - "comment": "Trend rate (1: sign, 2: formatted rate with unit)" + "comment": "Scanner failed to start" } }, - "version": "1.0" + "version": "1.1" } diff --git a/G6SensorKitUI/Views/G6DetailViews.swift b/G6SensorKitUI/Views/G6DetailViews.swift index d8edcc3..c2e12ad 100644 --- a/G6SensorKitUI/Views/G6DetailViews.swift +++ b/G6SensorKitUI/Views/G6DetailViews.swift @@ -149,7 +149,10 @@ struct G6CalibrationView: View { return } - cgmManager.enqueue(.calibrateSensor(toMgDL: valueMgDL, at: Date())) + // Flagged so a calibration that never lands is reported rather than + // silently lost. It is never retried: it only means anything next to + // the fingerstick it came from. + cgmManager.enqueue(.calibrateSensor(toMgDL: valueMgDL, at: Date()), notifyIfUndelivered: true) didSubmit() } } diff --git a/G6SensorKitUI/Views/G6OnboardingViews.swift b/G6SensorKitUI/Views/G6OnboardingViews.swift index 7655934..34a81ae 100644 --- a/G6SensorKitUI/Views/G6OnboardingViews.swift +++ b/G6SensorKitUI/Views/G6OnboardingViews.swift @@ -19,6 +19,21 @@ import LoopKitUI import G6SensorKit import G6SensorCore +#if targetEnvironment(simulator) +/// Simulator stand-in for the camera scanner, which needs hardware the +/// simulator lacks: the scan buttons stay visible there and deliver a +/// fixed payload, so the whole scan flow can be exercised without a +/// device. Compiled out on hardware. Both payloads are real, public +/// Dexcom packaging data (the same ones the parser tests use). +enum G6DemoScan { + /// The full Data Matrix from a public G6 transmitter box photo. + static let transmitterBox = G6PackageScan(payload: + "241STT-OM-001\u{1D}1018038762\u{1D}2188H03B\u{1D}17250226") + /// A real, public G6 applicator label. + static let applicator = G6PackageScan(payload: "10731863521434687D2405937") +} +#endif + // MARK: - Shared building blocks /// A numbered instruction row used by the placement guide. @@ -74,6 +89,75 @@ struct G6ContinueButton: View { } } +/// Large per-character boxes over a hidden text field that does the real +/// input. Shared by the transmitter-ID screen (6 boxes, alphanumeric) +/// and the sensor-code screen (4 boxes, numeric). +struct G6CodeBoxField: View { + enum AllowedCharacters { + case alphanumericUppercased + case numeric + } + + @Binding var text: String + let length: Int + let allowedCharacters: AllowedCharacters + var focused: FocusState.Binding + + private var filteredText: Binding { + Binding( + get: { text }, + set: { newValue in + let filtered: String + switch allowedCharacters { + case .alphanumericUppercased: + filtered = newValue.filter { $0.isLetter || $0.isNumber }.uppercased() + case .numeric: + filtered = newValue.filter(\.isNumber) + } + text = String(filtered.prefix(length)) + } + ) + } + + var body: some View { + ZStack { + TextField("", text: filteredText) + .focused(focused) + .keyboardType(allowedCharacters == .alphanumericUppercased ? .asciiCapable : .numberPad) + .autocorrectionDisabled() + .textInputAutocapitalization(allowedCharacters == .alphanumericUppercased ? .characters : .never) + .foregroundColor(.clear) + .accentColor(.clear) + .opacity(0.02) + + HStack(spacing: 10) { + ForEach(0 ..< length, id: \.self) { index in + let characters = Array(text) + let character = index < characters.count ? String(characters[index]) : "" + let isActive = index == characters.count + + Text(character) + .font(.title.weight(.semibold).monospaced()) + .frame(maxWidth: .infinity) + .frame(height: 64) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isActive ? Color.accentColor : Color(.separator), lineWidth: isActive ? 2 : 1) + ) + } + } + .allowsHitTesting(false) + } + .frame(height: 64) + .contentShape(Rectangle()) + .onTapGesture { focused.wrappedValue = true } + } +} + // MARK: - 1. Introduction /// Product hero, in the shape G7SensorKit uses: name, device, a short @@ -172,6 +256,10 @@ struct G6TransmitterIDEntryView: View { @State private var transmitterID: String = "" @State private var validationMessage: String? + @State private var showScanner = false + @State private var showCameraDeniedAlert = false + @State private var scanFailedMessage: String? + @FocusState private var fieldFocused: Bool init(initialValue: String, didSubmit: @escaping (String) -> Void) { self.initialValue = initialValue @@ -202,24 +290,42 @@ struct G6TransmitterIDEntryView: View { G6TransmitterBackGlyph(size: 150) } - TextField(LocalizedString("6-character ID", comment: "Transmitter ID text field placeholder"), text: $transmitterID) - .textCase(.uppercase) - .autocorrectionDisabled() - .textInputAutocapitalization(.characters) - .font(.title3.monospaced()) - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(10) - .onChange(of: transmitterID) { newValue in - transmitterID = String(newValue.uppercased().prefix(6)) - validationMessage = nil - } + G6CodeBoxField( + text: $transmitterID, + length: 6, + allowedCharacters: .alphanumericUppercased, + focused: $fieldFocused + ) + .frame(maxWidth: .infinity) + .onChange(of: transmitterID) { + validationMessage = nil + autoSubmitIfComplete() + } - if let validationMessage = validationMessage { - Text(validationMessage) - .font(.subheadline) - .foregroundColor(.red) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 6) { + if let validationMessage = validationMessage { + Text(validationMessage) + .foregroundColor(.red) + } + if let scanFailedMessage = scanFailedMessage { + Text(verbatim: scanFailedMessage) + .foregroundColor(.red) + } + } + .font(.subheadline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + if isScanAvailable { + Button(action: scanTapped) { + HStack(spacing: 8) { + Image(systemName: "barcode.viewfinder") + Text(LocalizedString("Scan package code", comment: "Scan package barcode button")) + } + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) } Text(LocalizedString("This app works with Dexcom G6 and Dexcom ONE transmitters. Older G5 transmitters, whose IDs begin with 4, are not supported.", comment: "Transmitter ID: supported hardware note")) @@ -229,11 +335,120 @@ struct G6TransmitterIDEntryView: View { } .padding() } + .onTapGesture { fieldFocused = false } G6ContinueButton(isEnabled: transmitterID.count == 6) { submit() } } + .sheet(isPresented: $showScanner) { scannerSheet } + .alert( + Text(LocalizedString("Camera access is off", comment: "Camera denied alert title")), + isPresented: $showCameraDeniedAlert + ) { + Button(action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) { Text(LocalizedString("Open Settings", comment: "Open Settings button")) } + Button(role: .cancel, action: {}) { Text(LocalizedString("Cancel", comment: "Cancel button")) } + } message: { + Text(LocalizedString("Allow camera access in Settings to scan the Data Matrix code on your Dexcom transmitter packaging.", comment: "Camera denied alert message")) + } + } + + /// A complete ID submits itself, typed or scanned: with the boxes full + /// there is nothing left to enter. A short delay lets the last box + /// visibly fill first. + private func autoSubmitIfComplete() { + guard transmitterID.count == 6, !isG5 else { + return + } + fieldFocused = false + let submitted = transmitterID + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + guard transmitterID == submitted else { return } + didSubmit(submitted) + } + } + + // MARK: - Package scanning + + /// The camera scanner needs hardware support and a camera usage + /// description; the simulator gets the demo-payload stand-in instead + /// (see `G6DemoScan`). + private var isScanAvailable: Bool { + #if targetEnvironment(simulator) + return true + #else + return G6PackageScannerView.isSupported + #endif + } + + private func scanTapped() { + #if targetEnvironment(simulator) + handleScan(G6DemoScan.transmitterBox) + #else + switch G6PackageScannerView.cameraAuthorization { + case .authorized: + showScanner = true + case .notDetermined: + G6PackageScannerView.requestCameraAccess { granted in + if granted { + showScanner = true + } else { + showCameraDeniedAlert = true + } + } + default: + showCameraDeniedAlert = true + } + #endif + } + + private func handleScan(_ scan: G6PackageScan) { + showScanner = false + scanFailedMessage = nil + + // A usable ID outweighs the GTIN check: the transmitter box's own + // Data Matrices carry no (01) — the GTIN sits on a separate linear + // barcode — so gating on isDexcomPackage would reject the box. + guard let candidate = scan.transmitterIDCandidate else { + scanFailedMessage = scan.isDexcomPackage + ? LocalizedString("Scanned a Dexcom package, but couldn't find a transmitter ID in it. Type the ID manually.", comment: "Scanned Dexcom barcode without a transmitter ID") + : LocalizedString("That doesn't look like a Dexcom package. Try again or type the ID manually.", comment: "Scanned non-Dexcom barcode") + return + } + + // Assigning the field fires the auto-submit path, same as typing. + transmitterID = candidate + } + + private func handleScanStartFailure(_: Error) { + showScanner = false + scanFailedMessage = LocalizedString("The camera couldn't start. Try again or type the ID manually.", comment: "Scanner failed to start") + } + + private var scannerSheet: some View { + NavigationView { + G6PackageScannerView(onScan: handleScan, onStartFailure: handleScanStartFailure) + .edgesIgnoringSafeArea(.all) + .navigationTitle(Text(LocalizedString("Scan package code", comment: "Scanner screen title"))) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { showScanner = false }) { + Text(LocalizedString("Cancel", comment: "Cancel button")) + } + } + } + } + } + + // MARK: - Submit + + private var isG5: Bool { + return transmitterID.hasPrefix("4") } private func submit() { @@ -241,7 +456,7 @@ struct G6TransmitterIDEntryView: View { return } - if transmitterID.hasPrefix("4") { + if isG5 { validationMessage = LocalizedString("IDs that begin with 4 belong to Dexcom G5 transmitters, which this app does not support.", comment: "Validation message for G5 transmitter ID") return } @@ -259,6 +474,9 @@ struct G6SensorCodeEntryView: View { @State private var sensorCode: String = "" @State private var validationMessage: String? @State private var confirmingNoCode = false + @State private var showScanner = false + @State private var showCameraDeniedAlert = false + @State private var scanFailedMessage: String? @FocusState private var codeFieldFocused: Bool var body: some View { @@ -275,23 +493,43 @@ struct G6SensorCodeEntryView: View { G6SensorCodeLabelGlyph(size: 150) } - TextField(LocalizedString("4-digit code", comment: "Sensor code text field placeholder"), text: $sensorCode) - .keyboardType(.numberPad) - .focused($codeFieldFocused) - .font(.title3.monospaced()) - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(10) - .onChange(of: sensorCode) { newValue in - sensorCode = String(newValue.filter(\.isNumber).prefix(4)) - validationMessage = nil - } + G6CodeBoxField( + text: $sensorCode, + length: 4, + allowedCharacters: .numeric, + focused: $codeFieldFocused + ) + .frame(maxWidth: .infinity) + .padding(.horizontal, 32) + .onChange(of: sensorCode) { + validationMessage = nil + autoSubmitIfComplete() + } - if let validationMessage = validationMessage { - Text(validationMessage) - .font(.subheadline) - .foregroundColor(.red) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 6) { + if let validationMessage = validationMessage { + Text(validationMessage) + .foregroundColor(.red) + } + if let scanFailedMessage = scanFailedMessage { + Text(verbatim: scanFailedMessage) + .foregroundColor(.red) + } + } + .font(.subheadline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + if isScanAvailable { + Button(action: scanTapped) { + HStack(spacing: 8) { + Image(systemName: "barcode.viewfinder") + Text(LocalizedString("Scan applicator code", comment: "Scan applicator barcode button")) + } + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) } G6CalloutBox( @@ -304,7 +542,7 @@ struct G6SensorCodeEntryView: View { } .padding() } - .onAppear { codeFieldFocused = true } + .onTapGesture { codeFieldFocused = false } VStack(spacing: 10) { G6ContinueButton( @@ -334,8 +572,114 @@ struct G6SensorCodeEntryView: View { } } } + .sheet(isPresented: $showScanner) { scannerSheet } + .alert( + Text(LocalizedString("Camera access is off", comment: "Camera denied alert title")), + isPresented: $showCameraDeniedAlert + ) { + Button(action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) { Text(LocalizedString("Open Settings", comment: "Open Settings button")) } + Button(role: .cancel, action: {}) { Text(LocalizedString("Cancel", comment: "Cancel button")) } + } message: { + Text(LocalizedString("Allow camera access in Settings to scan the Data Matrix code on your sensor applicator.", comment: "Camera denied alert message (sensor code)")) + } + } + + /// A complete code submits itself when it is a known factory code, + /// typed or scanned. Unknown codes show the validation message instead + /// of advancing. + private func autoSubmitIfComplete() { + guard sensorCode.count == 4 else { + return + } + guard SensorCode(sensorCode) != nil else { + validationMessage = LocalizedString("That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.", comment: "Validation message for unrecognized sensor code") + return + } + codeFieldFocused = false + let submitted = sensorCode + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + guard sensorCode == submitted else { return } + didSubmit(submitted) + } + } + + // MARK: - Package scanning + + /// The camera scanner needs hardware support and a camera usage + /// description; the simulator gets the demo-payload stand-in instead + /// (see `G6DemoScan`). + private var isScanAvailable: Bool { + #if targetEnvironment(simulator) + return true + #else + return G6PackageScannerView.isSupported + #endif + } + + private func scanTapped() { + #if targetEnvironment(simulator) + handleScan(G6DemoScan.applicator) + #else + switch G6PackageScannerView.cameraAuthorization { + case .authorized: + showScanner = true + case .notDetermined: + G6PackageScannerView.requestCameraAccess { granted in + if granted { + showScanner = true + } else { + showCameraDeniedAlert = true + } + } + default: + showCameraDeniedAlert = true + } + #endif } + private func handleScan(_ scan: G6PackageScan) { + showScanner = false + scanFailedMessage = nil + + // Applicator labels carry no GTIN, so unlike the transmitter-ID + // screen there is no Dexcom-package gate: either a sensor code + // falls out of the payload or it does not. + guard let candidate = scan.sensorCodeCandidate else { + scanFailedMessage = LocalizedString("Couldn't find a sensor code in that barcode. The code is in the Data Matrix on the applicator, not the sensor box.", comment: "Scanned barcode without a sensor code") + return + } + + // Assigning the field fires the auto-submit path, same as typing. + sensorCode = candidate + } + + private func handleScanStartFailure(_: Error) { + showScanner = false + scanFailedMessage = LocalizedString("The camera couldn't start. Try again or type the code manually.", comment: "Scanner failed to start") + } + + private var scannerSheet: some View { + NavigationView { + G6PackageScannerView(onScan: handleScan, onStartFailure: handleScanStartFailure) + .edgesIgnoringSafeArea(.all) + .navigationTitle(Text(LocalizedString("Scan applicator code", comment: "Scanner screen title (sensor code)"))) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { showScanner = false }) { + Text(LocalizedString("Cancel", comment: "Cancel button")) + } + } + } + } + } + + // MARK: - Submit + private func submit() { guard SensorCode(sensorCode) != nil else { validationMessage = LocalizedString("That code isn't one this app recognizes. Check the digits on the applicator label, or start without a code.", comment: "Validation message for unrecognized sensor code") diff --git a/G6SensorKitUI/Views/G6PackageScannerView.swift b/G6SensorKitUI/Views/G6PackageScannerView.swift new file mode 100644 index 0000000..eb0f057 --- /dev/null +++ b/G6SensorKitUI/Views/G6PackageScannerView.swift @@ -0,0 +1,85 @@ +// +// G6PackageScannerView.swift +// G6SensorKitUI +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import AVFoundation +import G6SensorCore +import SwiftUI +import VisionKit + +/// Camera scanner for the GS1 Data Matrix barcodes on Dexcom packaging: +/// the applicator label and the transmitter box. The box carries two Data +/// Matrices — serial-only (21) and the full (241)(10)(21)(17) — and +/// either yields the transmitter ID. +struct G6PackageScannerView: UIViewControllerRepresentable { + let onScan: (G6PackageScan) -> Void + let onStartFailure: (Error) -> Void + + static var isSupported: Bool { + return DataScannerViewController.isSupported + && Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription") != nil + } + + static var cameraAuthorization: AVAuthorizationStatus { + return AVCaptureDevice.authorizationStatus(for: .video) + } + + static func requestCameraAccess(completion: @escaping (Bool) -> Void) { + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { completion(granted) } + } + } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.dataMatrix])], + qualityLevel: .balanced, + recognizesMultipleItems: false, + isHighFrameRateTrackingEnabled: false, + isHighlightingEnabled: true + ) + scanner.delegate = context.coordinator + do { + try scanner.startScanning() + } catch { + DispatchQueue.main.async { onStartFailure(error) } + } + return scanner + } + + func updateUIViewController(_: DataScannerViewController, context _: Context) {} + + func makeCoordinator() -> Coordinator { + return Coordinator(onScan: onScan) + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + private let onScan: (G6PackageScan) -> Void + private var didDeliver = false + + init(onScan: @escaping (G6PackageScan) -> Void) { + self.onScan = onScan + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + didAdd addedItems: [RecognizedItem], + allItems _: [RecognizedItem] + ) { + guard !didDeliver else { return } + for item in addedItems { + guard case let .barcode(barcode) = item, + let payload = barcode.payloadStringValue + else { continue } + didDeliver = true + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + onScan(G6PackageScan(payload: payload)) + dataScanner.stopScanning() + return + } + } + } +} diff --git a/G6SensorKitUI/Views/G6SettingsView.swift b/G6SensorKitUI/Views/G6SettingsView.swift index 1cc7742..37867d2 100644 --- a/G6SensorKitUI/Views/G6SettingsView.swift +++ b/G6SensorKitUI/Views/G6SettingsView.swift @@ -193,8 +193,35 @@ final class G6SettingsViewModel: ObservableObject, G6CGMManagerObserver { } } + /// A stop that has not reached the transmitter. The session keeps + /// running until it does, so the screen must not look as if the stop + /// either worked or was ignored — a tester pressed the button three times + /// because nothing changed. + var pendingSessionStop: QueuedCommand? { + state.pendingSessionStop + } + + /// A stop or calibration that failed to send or expired unsent, while + /// it is still worth telling the user about. Starts are left out: a + /// failed start already shows its own explanation. + var undeliveredCommandNotice: String? { + guard let undelivered = state.outstandingUndeliveredCommand else { + return nil + } + switch undelivered.entry.command { + case .stopSensor: + return LocalizedString("The stop could not be sent to the transmitter, so the session is still running. Stop it again with your phone close to the transmitter.", comment: "Shown when a session stop failed to send") + case .calibrateSensor: + return LocalizedString("Your last calibration could not be sent to the transmitter and was not applied. To calibrate, take a new fingerstick and enter it.", comment: "Shown when a calibration failed to send or expired unsent") + case .startSensor, .resetTransmitter: + return nil + } + } + func stopSensor() { - cgmManager.enqueue(.stopSensor(at: Date())) + // Flagged: if the stop never lands, the user believes the session + // has ended while it carries on, so they must be told. + cgmManager.enqueue(.stopSensor(at: Date()), notifyIfUndelivered: true) } /// Current and previous log files. The manager's state is written out @@ -277,6 +304,38 @@ struct G6SettingsView: View { } } + if let stop = viewModel.pendingSessionStop { + if stop.isDelayed() { + Label { + Text(LocalizedString("The stop is still waiting for the transmitter, so the session is still running. It is sent as soon as the transmitter connects. Keep your phone close to the transmitter; if its battery is low, it may not connect.", comment: "Shown while a session stop has waited several connection cycles")) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } else { + Label { + Text(LocalizedString("Session stop queued — it is sent the next time the transmitter connects, which can take up to 5 minutes. Readings continue until then.", comment: "Shown while a session stop is waiting for the next connection")) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + } icon: { + ProgressView() + } + } + } + + if let notice = viewModel.undeliveredCommandNotice { + Label { + Text(notice) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + // Only meaningful when nothing is running. A stored failure can // outlive the situation that produced it — it is persisted, so it // survives relaunches and rebuilds — and a warning about not being @@ -424,19 +483,25 @@ struct G6SettingsView: View { .buttonStyle(G6RowButtonStyle()) .disabled(!viewModel.canCalibrate) - Button(role: .destructive) { - showingStopConfirmation = true - } label: { - Label(LocalizedString("Stop Sensor Session", comment: "Button to stop the sensor session"), systemImage: "stop.circle") - } - .confirmationDialog( - LocalizedString("Stop this sensor session?", comment: "Confirmation title for stopping a session"), - isPresented: $showingStopConfirmation, - titleVisibility: .visible - ) { - Button(LocalizedString("Stop Session", comment: "Confirm stop session"), role: .destructive, action: viewModel.stopSensor) - } message: { - Text(LocalizedString("You will stop getting readings until you start a new sensor. A stopped session cannot be restarted.", comment: "Confirmation message for stopping a session")) + if viewModel.pendingSessionStop != nil { + // Already queued; another tap would only replace it. + Label(LocalizedString("Stopping Sensor Session…", comment: "Disabled row shown while a session stop waits for the transmitter"), systemImage: "stop.circle") + .foregroundStyle(.secondary) + } else { + Button(role: .destructive) { + showingStopConfirmation = true + } label: { + Label(LocalizedString("Stop Sensor Session", comment: "Button to stop the sensor session"), systemImage: "stop.circle") + } + .confirmationDialog( + LocalizedString("Stop this sensor session?", comment: "Confirmation title for stopping a session"), + isPresented: $showingStopConfirmation, + titleVisibility: .visible + ) { + Button(LocalizedString("Stop Session", comment: "Confirm stop session"), role: .destructive, action: viewModel.stopSensor) + } message: { + Text(LocalizedString("You will stop getting readings until you start a new sensor. A stopped session cannot be restarted.", comment: "Confirmation message for stopping a session")) + } } } } footer: { diff --git a/Package.swift b/Package.swift index 79005f4..815577b 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ import PackageDescription let package = Package( name: "G6SensorCore", platforms: [ - .iOS(.v15), + .iOS(.v17), .macOS(.v13), ], products: [ diff --git a/Scripts/generate_project.rb b/Scripts/generate_project.rb index e0b9b6f..5ce1b7b 100644 --- a/Scripts/generate_project.rb +++ b/Scripts/generate_project.rb @@ -20,7 +20,7 @@ ROOT = File.expand_path('..', __dir__) PROJECT_PATH = File.join(ROOT, 'G6SensorKit.xcodeproj') -DEPLOYMENT_TARGET = '15.1' +DEPLOYMENT_TARGET = '17.6' SWIFT_VERSION = '5.9' BUNDLE_PREFIX = 'org.nightscout' diff --git a/Sources/G6SensorCore/Pairing/GS1DataMatrixParser.swift b/Sources/G6SensorCore/Pairing/GS1DataMatrixParser.swift new file mode 100644 index 0000000..e161428 --- /dev/null +++ b/Sources/G6SensorCore/Pairing/GS1DataMatrixParser.swift @@ -0,0 +1,207 @@ +// +// GS1DataMatrixParser.swift +// G6SensorKit +// +// Ported from DexKit (Pairing/GS1DataMatrixParser.swift), trimmed to G6 +// and extended with applicator sensor-code extraction (AI 240) and +// FNC1-stripped payload fallbacks. +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// One GS1 element string: application identifier + value. +public struct GS1Element: Equatable { + public let ai: String + public let value: String +} + +/// Pure GS1 Data Matrix parser for Dexcom packaging barcodes. +/// +/// The Data Matrix on Dexcom packaging encodes (GS1 element structure, +/// verified against our own captures of G6/ONE packaging): +/// - AI (01) GTIN: 14 digits; Dexcom company prefix `0038627` +/// - AI (21) serial: G6: 6-char transmitter ID; G7: 12-char sensor serial +/// - AI (240) additional product ID: G6: the 4-digit sensor code on the +/// applicator label; G7: the 4-digit pairing code +/// - AI (10) lot, AI (11) production date, AI (17) expiry +/// Variable-length AIs are terminated by FNC1 (ASCII GS, 0x1D) when more +/// elements follow. +public enum GS1DataMatrixParser { + /// GS1 FNC1 separator as delivered in scanned payload strings. + public static let fnc1: Character = "\u{1D}" + + private struct AIInfo { + let fixedLength: Int? + let maxLength: Int + } + + /// AI registry: fixed-length AIs never need a trailing FNC1; variable + /// ones are read up to FNC1 or end of payload. + private static let registry: [String: AIInfo] = [ + "01": AIInfo(fixedLength: 14, maxLength: 14), // GTIN + "11": AIInfo(fixedLength: 6, maxLength: 6), // production date YYMMDD + "17": AIInfo(fixedLength: 6, maxLength: 6), // expiry YYMMDD + "10": AIInfo(fixedLength: nil, maxLength: 20), // lot/batch + "21": AIInfo(fixedLength: nil, maxLength: 20), // serial + "240": AIInfo(fixedLength: nil, maxLength: 30), // additional product ID (G6 sensor code / G7 pairing code) + "241": AIInfo(fixedLength: nil, maxLength: 30), // customer part number + "250": AIInfo(fixedLength: nil, maxLength: 30) // secondary serial + ] + + /// Parses a scanned payload into GS1 elements. Tolerates the AIM + /// symbology identifier ("]d1"/"]d2") and/or a leading FNC1 that some + /// scanners prepend. Stops at the first unrecognized AI and keeps + /// everything parsed up to that point. + public static func parse(_ payload: String) -> [GS1Element] { + var remaining = Substring(normalize(payload)) + var elements: [GS1Element] = [] + + while !remaining.isEmpty { + // Skip stray FNC1 separators (robustness). + if remaining.first == fnc1 { + remaining = remaining.dropFirst() + continue + } + + guard let (ai, info) = readAI(remaining) else { + break + } + remaining = remaining.dropFirst(ai.count) + + let value: Substring + if let fixed = info.fixedLength { + guard remaining.count >= fixed else { break } + value = remaining.prefix(fixed) + remaining = remaining.dropFirst(fixed) + } else { + let end = remaining.firstIndex(of: fnc1) ?? remaining.endIndex + value = remaining[.. info.maxLength { + break + } + } + elements.append(GS1Element(ai: ai, value: String(value))) + } + + return elements + } + + /// Strips the AIM symbology identifier and any leading FNC1. + static func normalize(_ payload: String) -> String { + var s = payload.trimmingCharacters(in: .whitespacesAndNewlines) + if s.hasPrefix("]d") { + // AIM identifier for Data Matrix: ]d1 = plain, ]d2 = GS1. + s = String(s.dropFirst(3)) + } + while s.first == fnc1 { + s = String(s.dropFirst()) + } + return s + } + + /// Longest-match AI read: 4 digits, then 3, then 2. + private static func readAI(_ s: Substring) -> (String, AIInfo)? { + for length in [4, 3, 2] { + guard s.count >= length else { continue } + let candidate = String(s.prefix(length)) + guard candidate.allSatisfy(\.isNumber) else { continue } + if let info = registry[candidate] { + return (candidate, info) + } + } + return nil + } +} + +/// A scanned Dexcom package, mapped to what G6 onboarding needs. +public struct G6PackageScan: Equatable { + public let elements: [GS1Element] + + /// The normalized payload, kept for the fallback candidate extraction: + /// some scanners strip the FNC1 separators, leaving one long string the + /// strict element parse cannot segment. + private let normalizedPayload: String + + public init(payload: String) { + normalizedPayload = GS1DataMatrixParser.normalize(payload) + elements = GS1DataMatrixParser.parse(payload) + } + + public func value(forAI ai: String) -> String? { + elements.first(where: { $0.ai == ai })?.value + } + + /// AI (01); identifies the product model. + public var gtin: String? { value(forAI: "01") } + /// AI (10) lot/batch. + public var lot: String? { value(forAI: "10") } + /// AI (21); G6: 6-char transmitter ID; G7: 12-char sensor serial. + public var serial: String? { value(forAI: "21") } + + /// Dexcom's GS1 company prefix (0038627…) inside the GTIN. + public var isDexcomPackage: Bool { + gtin?.hasPrefix("0038627") == true + } + + /// The AI (21) serial as a transmitter ID. Three shapes occur: the + /// serial is the ID exactly; the ID is followed by a non-alphanumeric + /// delimiter and trailing packaging data (seen on G6 transmitter boxes: + /// "-"); or the payload lost its FNC1 separators, in which + /// case the raw payload is scanned for the pattern. A 12-char + /// alphanumeric G7 serial never matches: the character after the + /// would-be ID is alphanumeric too. G5 rejection ("4…" prefix) is left + /// to the entry view, matching how a typed ID is handled. + public var transmitterIDCandidate: String? { + if let serial = serial, let id = Self.transmitterIDPrefix(of: serial) { + return id + } + guard isDexcomPackage else { return nil } + var rest = normalizedPayload[...] + while let range = rest.range(of: "21") { + if let id = Self.transmitterIDPrefix(of: String(rest[range.upperBound...])) { + return id + } + rest = rest[range.upperBound...] + } + return nil + } + + /// The AI (240) field as a sensor code. On G6 the applicator's Data + /// Matrix carries the 4-digit session code there (the field G7 uses + /// for its pairing code). It is the last element on applicator labels, + /// so an FNC1-stripped payload is recovered by reading the tail. + /// Applicator labels carry no GTIN, so unlike the transmitter ID this + /// is not gated on `isDexcomPackage`. Validity against the known-code + /// table is left to the entry view, matching a typed code. + public var sensorCodeCandidate: String? { + if let pin = value(forAI: "240"), pin.count == 4, pin.allSatisfy(\.isNumber) { + return pin + } + let tail = normalizedPayload.suffix(7) + guard tail.count == 7, + tail.hasPrefix("240"), + tail.dropFirst(3).allSatisfy(\.isNumber) + else { return nil } + return String(tail.dropFirst(3)) + } + + /// The leading 6 characters when they are alphanumeric and the string + /// either ends there or continues with a non-alphanumeric delimiter. + /// FNC1 does not count as a delimiter: a 6-char run followed by FNC1 is + /// a complete (21) field the strict parse handles, and in the fallback + /// scan it is the middle of a longer serial (a G7's 12-char serial + /// contains "21" six digits in), never a truncated transmitter ID. + private static func transmitterIDPrefix(of serial: String) -> String? { + guard serial.count >= 6 else { return nil } + let prefix = serial.prefix(6) + guard prefix.allSatisfy({ $0.isLetter || $0.isNumber }) else { return nil } + if serial.count == 6 { + return prefix.uppercased() + } + let next = serial.dropFirst(6).first! + guard !next.isLetter, !next.isNumber, next != GS1DataMatrixParser.fnc1 else { return nil } + return prefix.uppercased() + } +} diff --git a/Sources/G6SensorCore/Session/CommandQueue.swift b/Sources/G6SensorCore/Session/CommandQueue.swift new file mode 100644 index 0000000..f8732ea --- /dev/null +++ b/Sources/G6SensorCore/Session/CommandQueue.swift @@ -0,0 +1,208 @@ +// +// CommandQueue.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// A command waiting in the queue, with what the queue needs to know about +/// its delivery. +public struct QueuedCommand: Equatable { + public let command: Command + + /// When the user asked for it. + public let queuedAt: Date + + /// Whether the user must hear about it if it does not get through. Set + /// for commands whose silent loss leaves the user believing something + /// happened that did not — a session they think they stopped carries on. + /// + /// It never causes a retry. A failed or expired command is gone, and the + /// user decides whether to send it again: a calibration in particular + /// must not reach the transmitter later than the fingerstick it came from. + public let notifyIfUndelivered: Bool + + public init(_ command: Command, queuedAt: Date = Date(), notifyIfUndelivered: Bool = false) { + self.command = command + self.queuedAt = queuedAt + self.notifyIfUndelivered = notifyIfUndelivered + } + + /// The command's own keys plus the delivery bookkeeping. Command ignores + /// keys it does not know, and entries persisted before these existed + /// read back as unflagged. + public init?(rawValue: Command.RawValue) { + guard let command = Command(rawValue: rawValue) else { + return nil + } + self.init( + command, + queuedAt: rawValue["queuedAt"] as? Date ?? Date(), + notifyIfUndelivered: rawValue["notifyIfUndelivered"] as? Bool ?? false + ) + } + + public var rawValue: Command.RawValue { + var raw = command.rawValue + raw["queuedAt"] = queuedAt + if notifyIfUndelivered { + raw["notifyIfUndelivered"] = true + } + return raw + } + + /// A flagged command still waiting long enough that it is not just the + /// usual gap between connections. It is still queued and goes out the + /// first time the transmitter connects; this only says the user should + /// know it has not yet. + public func isDelayed(now: Date = Date()) -> Bool { + guard notifyIfUndelivered else { + return false + } + return now.timeIntervalSince(queuedAt) > CommandQueue.delayedInterval + } +} + +/// A flagged command that left the queue without reaching the transmitter. +/// Kept so the user can be told and can act on it; the command itself is +/// never sent again. +public struct UndeliveredCommand: Equatable { + public enum Reason: String { + /// The send was attempted and broke down in transit. + case sendFailed + /// It outlived its usefulness while waiting — a calibration older + /// than the stale window — and was dropped unsent. + case expired + } + + public let entry: QueuedCommand + public let reason: Reason + public let date: Date + + /// The user has seen the alert. The alert is not raised again, though a + /// stop or start that is still wrong stays visible in settings. + public var acknowledged: Bool + + public init(entry: QueuedCommand, reason: Reason, date: Date = Date(), acknowledged: Bool = false) { + self.entry = entry + self.reason = reason + self.date = date + self.acknowledged = acknowledged + } + + public init?(rawValue: [String: Any]) { + guard + let entryRaw = rawValue["entry"] as? Command.RawValue, + let entry = QueuedCommand(rawValue: entryRaw), + let reason = (rawValue["reason"] as? String).flatMap(Reason.init(rawValue:)), + let date = rawValue["date"] as? Date + else { + return nil + } + self.init(entry: entry, reason: reason, date: date, acknowledged: rawValue["acknowledged"] as? Bool ?? false) + } + + public var rawValue: [String: Any] { + [ + "entry": entry.rawValue, + "reason": reason.rawValue, + "date": date, + "acknowledged": acknowledged + ] + } +} + +/// A persisted queue of transmitter commands awaiting the next connection. +/// +/// Pure value logic, extracted from the G6 manager so the supersede and +/// stale-drop rules can be tested without LoopKit. The manager mirrors +/// `rawValues` into its persisted state so queued commands survive relaunch. +public struct CommandQueue: Equatable { + + public static let staleCalibrationInterval: TimeInterval = .minutes(5) + + /// Three missed connection cycles. One missed cycle is ordinary; three + /// in a row means something is wrong with the link. + public static let delayedInterval: TimeInterval = .minutes(15) + + public private(set) var entries: [QueuedCommand] + + public var commands: [Command] { + entries.map(\.command) + } + + public init(_ entries: [QueuedCommand] = []) { + self.entries = entries + } + + public init(_ commands: [Command]) { + self.init(commands.map { QueuedCommand($0) }) + } + + public init(rawValues: [Command.RawValue]) { + self.init(rawValues.compactMap(QueuedCommand.init(rawValue:))) + } + + public var rawValues: [Command.RawValue] { + entries.map(\.rawValue) + } + + public var isEmpty: Bool { + entries.isEmpty + } + + /// Queues a command for the next connection cycle. Newest wins: any + /// queued command the new one supersedes is dropped first, so repeated + /// trips through setup cannot stack session starts. + /// + /// - Parameter notifyIfUndelivered: tell the user if the command is + /// delayed, fails to send or expires unsent. Never causes a retry. + /// - Returns: the number of queued commands the new one replaced. + @discardableResult public mutating func enqueue(_ command: Command, notifyIfUndelivered: Bool = false, now: Date = Date()) -> Int { + let before = entries.count + entries.removeAll { $0.command.supersededBy(command) } + let superseded = before - entries.count + entries.append(QueuedCommand(command, queuedAt: now, notifyIfUndelivered: notifyIfUndelivered)) + return superseded + } + + /// Pops the next command to send, silently dropping stale calibrations. + /// + /// - Returns: the command to send, plus any commands dropped as stale + /// (for logging). + public mutating func dequeue(now: Date = Date()) -> (next: Command?, dropped: [Command]) { + let (next, dropped) = dequeueEntry(now: now) + return (next?.command, dropped.map(\.command)) + } + + /// As `dequeue`, keeping the delivery bookkeeping so the caller can tell + /// the user about a flagged command that is dropped or fails. + public mutating func dequeueEntry(now: Date = Date()) -> (next: QueuedCommand?, dropped: [QueuedCommand]) { + var dropped: [QueuedCommand] = [] + var next: QueuedCommand? + while let candidate = entries.first { + entries.removeFirst() + if case let .calibrateSensor(_, date) = candidate.command, + now.timeIntervalSince(date) > Self.staleCalibrationInterval + { + dropped.append(candidate) + continue + } + next = candidate + break + } + return (next, dropped) + } + + /// The first flagged command that has waited long enough to tell the + /// user about. + public func firstDelayed(now: Date = Date()) -> QueuedCommand? { + entries.first { $0.isDelayed(now: now) } + } + + public mutating func removeAll() { + entries.removeAll() + } +} diff --git a/Sources/G6SensorCore/Session/TransmitterSession.swift b/Sources/G6SensorCore/Session/TransmitterSession.swift index 3176be4..17d77a9 100644 --- a/Sources/G6SensorCore/Session/TransmitterSession.swift +++ b/Sources/G6SensorCore/Session/TransmitterSession.swift @@ -466,10 +466,10 @@ public final class TransmitterSession: TransmitterConnectionDelegate { let ack = try peripheral.requestBackfill(startTime: startTime, endTime: endTime) - log.default("Backfill acknowledged: status %{public}@, backfillStatus %{public}@, id %{public}@, range %{public}@-%{public}@, length %{public}@, crc %{public}@", - String(ack.status), String(ack.backfillStatus), String(ack.identifier), - String(ack.startTime), String(ack.endTime), - String(ack.bufferLength), String(format: "%04x", ack.bufferCRC)) + log.default("Backfill acknowledged: %{public}@", + "status \(ack.status), backfillStatus \(ack.backfillStatus), id \(ack.identifier), " + + "range \(ack.startTime)-\(ack.endTime), length \(ack.bufferLength), " + + "crc \(String(format: "%04x", ack.bufferCRC))") // Nothing stored for the window: an empty result, not a failure. guard ack.bufferLength > 0 else { diff --git a/Sources/G6SensorCore/Support/Locked.swift b/Sources/G6SensorCore/Support/Locked.swift index fd6e35b..4a82830 100644 --- a/Sources/G6SensorCore/Support/Locked.swift +++ b/Sources/G6SensorCore/Support/Locked.swift @@ -31,6 +31,7 @@ internal class Locked { } } + @discardableResult func mutate(_ changes: (_ value: inout T) -> Void) -> T { os_unfair_lock_lock(&lock) defer { os_unfair_lock_unlock(&lock) } diff --git a/Sources/G6SensorCore/Transport/OSLog.swift b/Sources/G6SensorCore/Transport/OSLog.swift index 847d5be..999052d 100644 --- a/Sources/G6SensorCore/Transport/OSLog.swift +++ b/Sources/G6SensorCore/Transport/OSLog.swift @@ -34,7 +34,8 @@ extension OSLog { } private func log(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) { - mirrorToFile(message, type: type, args) + let rendered = render(message, args) + mirrorToFile(rendered, type: type) switch args.count { case 0: @@ -50,7 +51,10 @@ extension OSLog { case 5: os_log(message, log: self, type: type, args[0], args[1], args[2], args[3], args[4]) default: - os_log(message, log: self, type: type, args) + // os_log can't take the array as varargs: handing it over reads + // garbage pointers for each %@ and crashes. Log the rendered line. + assertionFailure("OSLog supports at most 5 arguments, got \(args.count): \(message)") + os_log("%{public}@", log: self, type: type, rendered) } } @@ -65,15 +69,17 @@ extension OSLog { return Self.categories[ObjectIdentifier(self)] ?? "G6SensorKit" } - /// Mirrors every OSLog line into the exportable file log. Format - /// specifiers are OSLog-flavoured (%{public}@), so the privacy qualifier - /// is stripped before String(format:) sees them. - private func mirrorToFile(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) { + /// Format specifiers are OSLog-flavoured (%{public}@), so the privacy + /// qualifier is stripped before String(format:) sees them. + private func render(_ message: StaticString, _ args: [CVarArg]) -> String { let template = "\(message)" .replacingOccurrences(of: "%{public}", with: "%") .replacingOccurrences(of: "%{private}", with: "%") - let rendered = args.isEmpty ? template : String(format: template, arguments: args) + return args.isEmpty ? template : String(format: template, arguments: args) + } + /// Mirrors every OSLog line into the exportable file log. + private func mirrorToFile(_ rendered: String, type: OSLogType) { let level: String switch type { case .debug: level = "DEBUG" diff --git a/Tests/G6SensorCoreTests/CommandQueueTests.swift b/Tests/G6SensorCoreTests/CommandQueueTests.swift new file mode 100644 index 0000000..26d3229 --- /dev/null +++ b/Tests/G6SensorCoreTests/CommandQueueTests.swift @@ -0,0 +1,173 @@ +// +// CommandQueueTests.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import XCTest +@testable import G6SensorCore + +class CommandQueueTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + func testEnqueueAppendsDistinctCommands() { + var queue = CommandQueue() + queue.enqueue(.startSensor(at: now, sensorCode: .none)) + queue.enqueue(.calibrateSensor(toMgDL: 120, at: now)) + + XCTAssertEqual(queue.commands.count, 2) + } + + func testEnqueueSupersedesSameKind() { + var queue = CommandQueue() + queue.enqueue(.startSensor(at: now, sensorCode: .none)) + queue.enqueue(.startSensor(at: now, sensorCode: .none)) + queue.enqueue(.calibrateSensor(toMgDL: 100, at: now)) + queue.enqueue(.calibrateSensor(toMgDL: 140, at: now)) + + XCTAssertEqual(queue.commands.count, 2) + guard case let .calibrateSensor(value, _) = queue.commands[1] else { + return XCTFail("expected calibration") + } + XCTAssertEqual(value, 140) + } + + func testEnqueueReturnsSupersededCount() { + var queue = CommandQueue() + queue.enqueue(.calibrateSensor(toMgDL: 100, at: now)) + queue.enqueue(.calibrateSensor(toMgDL: 110, at: now)) + // The 110 enqueue already superseded the 100 one, so a single queued + // calibration remains for the 120 to replace. + let superseded = queue.enqueue(.calibrateSensor(toMgDL: 120, at: now)) + XCTAssertEqual(superseded, 1) + XCTAssertEqual(queue.commands.count, 1) + } + + func testDequeueDropsStaleCalibrations() { + var queue = CommandQueue() + // 6 minutes old at dequeue time: beyond the 5-minute window. + queue.enqueue(.calibrateSensor(toMgDL: 120, at: now.addingTimeInterval(-360))) + queue.enqueue(.stopSensor(at: now)) + + let (next, dropped) = queue.dequeue(now: now) + + XCTAssertEqual(dropped.count, 1) + guard case .stopSensor? = next else { + return XCTFail("expected stopSensor, got \(String(describing: next))") + } + XCTAssertTrue(queue.isEmpty) + } + + func testDequeueKeepsFreshCalibration() { + var queue = CommandQueue() + queue.enqueue(.calibrateSensor(toMgDL: 120, at: now.addingTimeInterval(-60))) + + let (next, dropped) = queue.dequeue(now: now) + + XCTAssertTrue(dropped.isEmpty) + guard case .calibrateSensor? = next else { + return XCTFail("expected calibration, got \(String(describing: next))") + } + } + + func testDequeueEmptyReturnsNil() { + var queue = CommandQueue() + let (next, dropped) = queue.dequeue(now: now) + XCTAssertNil(next) + XCTAssertTrue(dropped.isEmpty) + } + + func testRawValuesRoundTrip() { + var queue = CommandQueue() + queue.enqueue(.startSensor(at: now, sensorCode: SensorCode("5931") ?? .none)) + queue.enqueue(.calibrateSensor(toMgDL: 123.4, at: now)) + queue.enqueue(.resetTransmitter) + + let restored = CommandQueue(rawValues: queue.rawValues) + XCTAssertEqual(restored, queue) + } + + func testRawValuesDropMalformedEntries() { + let queue = CommandQueue(rawValues: [["action": 99], [:]]) + XCTAssertTrue(queue.isEmpty) + } + + // MARK: - Delivery tracking + + func testFlagAndQueuedAtSurviveRawValues() { + var queue = CommandQueue() + queue.enqueue(.stopSensor(at: now), notifyIfUndelivered: true, now: now) + + let restored = CommandQueue(rawValues: queue.rawValues) + XCTAssertEqual(restored, queue) + XCTAssertEqual(restored.entries.first?.notifyIfUndelivered, true) + XCTAssertEqual(restored.entries.first?.queuedAt, now) + } + + func testEntriesPersistedWithoutBookkeepingReadBackUnflagged() { + let legacy = Command.stopSensor(at: now).rawValue + let queue = CommandQueue(rawValues: [legacy]) + XCTAssertEqual(queue.entries.first?.notifyIfUndelivered, false) + XCTAssertNil(queue.firstDelayed(now: now.addingTimeInterval(3600))) + } + + func testFlaggedCommandIsDelayedOnlyAfterInterval() { + var queue = CommandQueue() + queue.enqueue(.stopSensor(at: now), notifyIfUndelivered: true, now: now) + + XCTAssertNil(queue.firstDelayed(now: now.addingTimeInterval(.minutes(14)))) + XCTAssertNotNil(queue.firstDelayed(now: now.addingTimeInterval(.minutes(16)))) + } + + func testUnflaggedCommandIsNeverDelayed() { + var queue = CommandQueue() + queue.enqueue(.startSensor(at: now, sensorCode: .none), now: now) + XCTAssertNil(queue.firstDelayed(now: now.addingTimeInterval(.hours(2)))) + } + + func testDequeuedCommandIsGoneFromQueue() { + // Nothing puts a sent command back: whether it lands or fails, the + // queue no longer holds it. + var queue = CommandQueue() + queue.enqueue(.stopSensor(at: now), notifyIfUndelivered: true, now: now) + + let (entry, _) = queue.dequeueEntry(now: now) + XCTAssertNotNil(entry) + XCTAssertTrue(queue.isEmpty) + XCTAssertNil(queue.dequeueEntry(now: now).next) + } + + func testFlaggedCalibrationIsNeverRetried() { + var queue = CommandQueue() + queue.enqueue(.calibrateSensor(toMgDL: 120, at: now), notifyIfUndelivered: true, now: now) + + let (entry, dropped) = queue.dequeueEntry(now: now) + XCTAssertTrue(dropped.isEmpty) + guard case .calibrateSensor? = entry?.command else { + return XCTFail("expected the calibration to be handed out once") + } + XCTAssertTrue(queue.isEmpty) + XCTAssertNil(queue.dequeueEntry(now: now.addingTimeInterval(60)).next) + } + + func testStaleFlaggedCalibrationIsReportedNotSent() { + var queue = CommandQueue() + let measured = now.addingTimeInterval(-360) + queue.enqueue(.calibrateSensor(toMgDL: 120, at: measured), notifyIfUndelivered: true, now: measured) + + let (next, dropped) = queue.dequeueEntry(now: now) + + XCTAssertNil(next) + XCTAssertEqual(dropped.count, 1) + XCTAssertEqual(dropped.first?.notifyIfUndelivered, true) + XCTAssertTrue(queue.isEmpty) + } + + func testUndeliveredCommandSurvivesRawValue() { + let entry = QueuedCommand(.calibrateSensor(toMgDL: 120, at: now), queuedAt: now, notifyIfUndelivered: true) + let undelivered = UndeliveredCommand(entry: entry, reason: .expired, date: now, acknowledged: true) + + XCTAssertEqual(UndeliveredCommand(rawValue: undelivered.rawValue), undelivered) + } +} diff --git a/Tests/G6SensorCoreTests/GS1DataMatrixParserTests.swift b/Tests/G6SensorCoreTests/GS1DataMatrixParserTests.swift new file mode 100644 index 0000000..c21b7d5 --- /dev/null +++ b/Tests/G6SensorCoreTests/GS1DataMatrixParserTests.swift @@ -0,0 +1,239 @@ +// +// GS1DataMatrixParserTests.swift +// G6SensorKit +// +// Ported from DexKit (DexKitTests/GS1DataMatrixParserTests.swift), trimmed +// to G6 alongside the parser. +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import XCTest +@testable import G6SensorCore + +/// Payloads follow the GS1 spec: a package Data Matrix = (01) GTIN, +/// (17) expiry, (10) lot, (21) serial, (240) additional product ID, with +/// FNC1 (\u{1D}) separating variable-length elements that aren't last. +final class GS1DataMatrixParserTests: XCTestCase { + private let fnc1 = "\u{1D}" + + // MARK: - Basic element extraction + + /// Real payload from a public G7 box image: (01) GTIN, (11) production + /// date, (17) expiry, (10) lot, (21) 12-char serial, (241) customer + /// part number. Note the box carries no (240): the pairing/sensor code + /// lives on the applicator label. + private var g7BoxPayload: String { + "01" + "00386270002839" + "11" + "230201" + "17" + "240731" + + "10" + "1523041787" + fnc1 + "21" + "448421319350" + fnc1 + "241" + "STP-AT-012301" + } + + func testParsesFullPayloadWithAIMPrefixAndFNC1() { + // ]d2 = AIM symbology identifier for GS1 Data Matrix + let elements = GS1DataMatrixParser.parse("]d2" + g7BoxPayload) + XCTAssertEqual(elements, [ + GS1Element(ai: "01", value: "00386270002839"), + GS1Element(ai: "11", value: "230201"), + GS1Element(ai: "17", value: "240731"), + GS1Element(ai: "10", value: "1523041787"), + GS1Element(ai: "21", value: "448421319350"), + GS1Element(ai: "241", value: "STP-AT-012301") + ]) + } + + func testParsesPayloadWithoutAIMPrefix() { + // VisionKit/Vision typically deliver the payload without "]d2". + let scan = G6PackageScan(payload: g7BoxPayload) + XCTAssertEqual(scan.gtin, "00386270002839") + XCTAssertEqual(scan.serial, "448421319350") + XCTAssertEqual(scan.value(forAI: "241"), "STP-AT-012301") + } + + func testStripsLeadingFNC1() { + // Some hardware scanners report the leading FNC1 of the first element. + let scan = G6PackageScan(payload: fnc1 + g7BoxPayload) + XCTAssertEqual(scan.gtin, "00386270002839") + XCTAssertEqual(scan.serial, "448421319350") + } + + // MARK: - Dexcom mapping + + func testG6PackageYieldsTransmitterIDCandidate() { + // G6 transmitter box: (21) serial is the 6-char transmitter ID. + let payload = "0100386270004070" + fnc1 + "218KAB12" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.transmitterIDCandidate, "8KAB12") + } + + func testSensorPackageYieldsNoTransmitterIDCandidate() { + // G7 box (the public payload above): (21) is a 12-char sensor + // serial, not a transmitter ID. + let scan = G6PackageScan(payload: g7BoxPayload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertNil(scan.transmitterIDCandidate) + XCTAssertNil(scan.sensorCodeCandidate) // the box carries no (240) + } + + func testFNC1StrippedG7BoxYieldsNoCandidates() { + // The same public payload as scanners and pastes deliver it without + // separators: the dates still parse, nothing G6-usable falls out. + let payload = "0100386270002839112302011724073110152304178721448421319350241STP-AT-012301" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.value(forAI: "11"), "230201") + XCTAssertEqual(scan.value(forAI: "17"), "240731") + XCTAssertNil(scan.transmitterIDCandidate) + XCTAssertNil(scan.sensorCodeCandidate) + } + + func testLowercaseSerialIsUppercased() { + let scan = G6PackageScan(payload: "0100386270004070" + fnc1 + "218kab12") + XCTAssertEqual(scan.transmitterIDCandidate, "8KAB12") + } + + func testNonDexcomGTINIsFlagged() { + // Another manufacturer's package: same GS1 shape, different company prefix. + let payload = "01" + "06991234567890" + fnc1 + "10LOT123" + fnc1 + "21991234ABCD5678" + let scan = G6PackageScan(payload: payload) + XCTAssertFalse(scan.isDexcomPackage) + XCTAssertEqual(scan.gtin, "06991234567890") + XCTAssertEqual(scan.lot, "LOT123") + XCTAssertEqual(scan.serial, "991234ABCD5678") + XCTAssertNil(scan.transmitterIDCandidate) + } + + // MARK: - G6 applicator sensor codes + + // The applicator payloads below are real, public G6 labels; the + // transmitter/sensor box structures mirror our own captures with + // serials, lots and dates anonymized. + + func testApplicatorYieldsSensorCodeCandidate() { + // Real public applicator labels: (10) 16-char lot glued to + // (240) + the 4-digit sensor code, FNC1 separators stripped. + let cases: [(payload: String, code: String)] = [ + ("10731863521434687D2405937", "5937"), + ("10527390421846477D2405931", "5931"), + ("10729148021365863F2409117", "9117") + ] + for (payload, code) in cases { + let scan = G6PackageScan(payload: payload) + XCTAssertEqual(scan.sensorCodeCandidate, code, payload) + XCTAssertNotNil(SensorCode(scan.sensorCodeCandidate), payload) + // No GTIN and no (21): an applicator label says nothing about the transmitter. + XCTAssertNil(scan.transmitterIDCandidate, payload) + XCTAssertFalse(scan.isDexcomPackage, payload) + } + } + + func testApplicatorWithFNC1YieldsSensorCodeCandidate() { + let payload = "10" + "731863521434687D" + fnc1 + "2405937" + let scan = G6PackageScan(payload: payload) + XCTAssertEqual(scan.lot, "731863521434687D") + XCTAssertEqual(scan.sensorCodeCandidate, "5937") + } + + func testApplicatorTailMustBeExactlyFourDigits() { + // "240" followed by 5 digits is not a sensor code. + let scan = G6PackageScan(payload: "10731863521434687D24059377") + XCTAssertNil(scan.sensorCodeCandidate) + } + + func testTransmitterBoxSerialWithTrailingPackagingData() { + // Transmitter box: (21) is the 6-char ID glued to "-". + let payload = "0100386270003072" + "241STT-GS-003" + fnc1 + + "10AB12CD34" + fnc1 + "218ZXY12-199270101" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.transmitterIDCandidate, "8ZXY12") + XCTAssertNil(scan.sensorCodeCandidate) + } + + func testFNC1StrippedTransmitterBoxYieldsTransmitterID() { + // Same box with every FNC1 stripped: the strict parse cannot + // segment past (241), so the raw-payload fallback recovers the ID. + let payload = "0100386270003072241STT-GS-00310AB12CD34218ZXY12-199270101" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertEqual(scan.transmitterIDCandidate, "8ZXY12") + } + + func testTransmitterBoxSerialOnlyBarcode() { + // Real public G6 transmitter box: the small Data Matrix carries + // only (21) + the 6-char transmitter ID. + let scan = G6PackageScan(payload: "2188H03B") + + XCTAssertEqual(scan.transmitterIDCandidate, "88H03B") + XCTAssertNil(scan.sensorCodeCandidate) + XCTAssertFalse(scan.isDexcomPackage) + } + + func testTransmitterBoxFullDataMatrix() { + // Same box's large Data Matrix: (241) customer part number, (10) + // lot, (21) serial, (17) expiry. The GTIN is a separate linear + // barcode on the box, so no (01) appears here. + let payload = "241STT-OM-001" + fnc1 + "1018038762" + fnc1 + "2188H03B" + fnc1 + "17250226" + let scan = G6PackageScan(payload: payload) + + XCTAssertEqual(scan.elements, [ + GS1Element(ai: "241", value: "STT-OM-001"), + GS1Element(ai: "10", value: "18038762"), + GS1Element(ai: "21", value: "88H03B"), + GS1Element(ai: "17", value: "250226") + ]) + XCTAssertEqual(scan.transmitterIDCandidate, "88H03B") + XCTAssertNil(scan.sensorCodeCandidate) + XCTAssertFalse(scan.isDexcomPackage) + } + + func testSensorBoxYieldsNoCandidates() { + // Sensor box: GTIN, customer part number, dates; no code at all. + // The sensor code is only on the applicator label. + let payload = "0100386270000927" + "2419500-46107380885" + "11251207" + "17270630" + let scan = G6PackageScan(payload: payload) + + XCTAssertTrue(scan.isDexcomPackage) + XCTAssertNil(scan.transmitterIDCandidate) + XCTAssertNil(scan.sensorCodeCandidate) + } + + // MARK: - Robustness + + func testVariableLengthSerialAtEndOfPayloadWithoutFNC1() { + let payload = "0100386270004758" + fnc1 + "21ABCD12345678" + XCTAssertEqual(GS1DataMatrixParser.parse(payload).last, + GS1Element(ai: "21", value: "ABCD12345678")) + } + + func testStopsAtUnknownAIKeepingParsedPrefix() { + // (99) is not in the registry; parsing stops but keeps (01) and (21). + let payload = "0100386270004758" + fnc1 + "218KAB12" + fnc1 + "99JUNK" + let elements = GS1DataMatrixParser.parse(payload) + XCTAssertEqual(elements, [ + GS1Element(ai: "01", value: "00386270004758"), + GS1Element(ai: "21", value: "8KAB12") + ]) + } + + func testTruncatedFixedLengthFieldStopsParsing() { + let payload = "0100386" // truncated GTIN + XCTAssertTrue(GS1DataMatrixParser.parse(payload).isEmpty) + } + + func testGarbageYieldsNoElements() { + XCTAssertTrue(GS1DataMatrixParser.parse("hello world").isEmpty) + XCTAssertTrue(GS1DataMatrixParser.parse("").isEmpty) + } + + func testFourDigitAIIsNotMisreadAsTwoDigitAI() { + // "240..." must read as AI 240, not AI 24 + garbage. + let payload = "0100386270004758" + fnc1 + "2401249" + XCTAssertEqual(GS1DataMatrixParser.parse(payload).last, + GS1Element(ai: "240", value: "1249")) + } +}