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..7fbbf6a 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 */; }; + 068F65E11AF1419388CBD953 /* G6SensorKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */; }; + 14589281DC7760165D5590E3 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */; }; + 1BFC8B0012423024BCEE84C4 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695593AE5C331A21B37C828 /* Locked.swift */; }; + 226AE1C008E3432034691E58 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAF739DBBA35C93627B2A12C /* Assets.xcassets */; }; + 25B13BDAFD609EC61E01ED42 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CADE8EBD6A8DBD34E5F08E /* LocalizedString.swift */; }; + 2921FB176CBA1E3FACF8C998 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8C69491E1F3F7F01BACF348A /* Localizable.xcstrings */; }; + 2A9D142A81AE191360B058B0 /* G6Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC51720925E26FFF9526D8 /* G6Alert.swift */; }; + 2C77CCC0A4FC873D451B1D9C /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2803D463F94F749B9B4C4480 /* Localizable.xcstrings */; }; + 2E9270FAE984E3B653790956 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */; }; + 332BA57D80F0D8A629C0881A /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F95502372EC14A32117B5F92 /* Foundation.framework */; }; + 3342E51E140EB78C3406DCFC /* G6SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8B421AE8197339D306979939 /* G6SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 39603B3649182CA37CE8237A /* G6CGMManager+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2C05844010D28B27D4E02D /* G6CGMManager+UI.swift */; }; + 3A518597A56D377383B5D384 /* G6CGMManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650CB121B3A64293DDF66491 /* G6CGMManagerState.swift */; }; + 3A7D53148400D95D5EBE9B20 /* G6Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC82866E887A9C466610864B /* G6Graphics.swift */; }; + 3B2B08D9C8ACC49731771581 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */; }; + 3D85A309CDAA70C948DB7D06 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C199CCC759EB31B7459FAF02 /* HKUnit.swift */; }; + 405A216F8D1752F1421D4887 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4847E689C846CC527559717B /* LocalizedString.swift */; }; + 4159FE6F9D1C67AA7D355705 /* G6SimulatedSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFB4FF079E0E059B0A2514C4 /* G6SimulatedSession.swift */; }; + 51AA80EED323BB1051CCF352 /* G6SensorKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5623B9849DDAC97251E7B413 /* G6CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB56C95B7CD81513D98B212 /* G6CGMManager.swift */; }; + 6C6A5725B781F8EC0283A2E3 /* G6ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20A6B1AD620BB43E52CA138 /* G6ActivityViewController.swift */; }; + 77CD64A55933B10835781892 /* G6UICoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60451EC9CB88AAED72C7B4C0 /* G6UICoordinator.swift */; }; + 7A17394DCB757B79A35A9401 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F66577CA895DC0539B94DB /* LoopKitUI.framework */; }; + 7B236774AE21607067257858 /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B421AE8197339D306979939 /* G6SensorKit.framework */; }; + 81CC3E50E4370774F5954A78 /* G6SensorKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15C3F478B37D28F487EA970 /* G6SensorKitPlugin.swift */; }; + 83FDE18A177B5C10B0504485 /* G6ShareCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9DBE6B77523AE6CF47FE179 /* G6ShareCredentialStore.swift */; }; + 84E98310287C4DAF8D376F5C /* G6SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B421AE8197339D306979939 /* G6SensorKit.framework */; }; + 8587D2CC46C9C9CB94B2D3FA /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E067B8007DAFB29BE2F03F48 /* HKUnit.swift */; }; + 85D8C77F66A1FF1AF57A8F17 /* G6ApplySensorViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5C11477C372E423D1838A5 /* G6ApplySensorViews.swift */; }; + 9068DC2E9303B2620EC57AF2 /* G6AlertEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF9D322E96EE881F0910FD /* G6AlertEvaluator.swift */; }; + 9068DD5A9EB5430807D21AE5 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F95502372EC14A32117B5F92 /* Foundation.framework */; }; + 934EFF804E6452CD19F40C04 /* G6CalibrationGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F99764DE6442A33728920B /* G6CalibrationGuideView.swift */; }; + 9D23800E9E278C62602AAF06 /* G6SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76778EA2C3FE465AE8FC8F /* G6SettingsView.swift */; }; + ABC18A66F55CAC750FAAA781 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED9872057F9AB73CCE989CF /* TimeInterval.swift */; }; + CF81E72F011FD1971AA15AA2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F95502372EC14A32117B5F92 /* Foundation.framework */; }; + D48D98C13312034AB8C11BA8 /* G6DetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB63314B843491AD67FAFB8 /* G6DetailViews.swift */; }; + DA3B54D9184C892B5F9A7B96 /* G6OnboardingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCCCBFAEC5AC32DCB17C0E0 /* G6OnboardingViews.swift */; }; + E5928066DDD984FCE765E888 /* G6PackageScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8E7A171900BF409A482908 /* G6PackageScannerView.swift */; }; + EE9148D1635A031059CB0474 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2202DFC274BBEC075ADF5E /* OSLog.swift */; }; + F3BF96B43DC44EAB997C9556 /* G6ReadingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9773F6BB69F46104A715B09 /* G6ReadingDetailView.swift */; }; + F5B695198564172CA054D830 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F66577CA895DC0539B94DB /* LoopKitUI.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 393AD5FC1C4D642198DBF3BA /* PBXContainerItemProxy */ = { + 1CA7CA75216CC94C77001C8A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = 05E8929C0E445F2264A8A045 /* Project object */; proxyType = 1; - remoteGlobalIDString = 3ADDB0CD055A33C01C7B54FE; + remoteGlobalIDString = 8F3461A960A54D2320B21FE4; remoteInfo = G6SensorKitUI; }; - 5DCC41D3B8AE0AF30CAAD2E3 /* PBXContainerItemProxy */ = { + 8C5FBFBE5966E33ED892663C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = 05E8929C0E445F2264A8A045 /* Project object */; proxyType = 1; - remoteGlobalIDString = 1B26058BD31E40741B0058A5; + remoteGlobalIDString = 215CD1147465A2E980A94D63; remoteInfo = G6SensorKit; }; - CC0AE83086E9B970A7244CCE /* PBXContainerItemProxy */ = { + AB91E4CF703C2153542F72B0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 9254155A3D36B6309735ED9B /* Project object */; + containerPortal = 05E8929C0E445F2264A8A045 /* Project object */; proxyType = 1; - remoteGlobalIDString = 1B26058BD31E40741B0058A5; + remoteGlobalIDString = 215CD1147465A2E980A94D63; remoteInfo = G6SensorKit; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 4FFBC74F2EB40DC4B5ED7514 /* Embed Frameworks */ = { + D0449C47C05DB7A8F1FC65C4 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - A81FAA3A42066C630B773A7A /* G6SensorKit.framework in Embed Frameworks */, - 80A0E78EA46BE2777673109D /* G6SensorKitUI.framework in Embed Frameworks */, + 3342E51E140EB78C3406DCFC /* G6SensorKit.framework in Embed Frameworks */, + 51AA80EED323BB1051CCF352 /* 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 = ""; }; + 14F99764DE6442A33728920B /* G6CalibrationGuideView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6CalibrationGuideView.swift; path = Views/G6CalibrationGuideView.swift; sourceTree = ""; }; + 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2803D463F94F749B9B4C4480 /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; + 2AB56C95B7CD81513D98B212 /* G6CGMManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManager.swift; sourceTree = ""; }; + 3695593AE5C331A21B37C828 /* Locked.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; + 3B2202DFC274BBEC075ADF5E /* OSLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + 3C2C05844010D28B27D4E02D /* G6CGMManager+UI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G6CGMManager+UI.swift"; sourceTree = ""; }; + 4847E689C846CC527559717B /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + 54CADE8EBD6A8DBD34E5F08E /* LocalizedString.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + 5BFF9D322E96EE881F0910FD /* G6AlertEvaluator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6AlertEvaluator.swift; sourceTree = ""; }; + 60451EC9CB88AAED72C7B4C0 /* G6UICoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6UICoordinator.swift; sourceTree = ""; }; + 650CB121B3A64293DDF66491 /* G6CGMManagerState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6CGMManagerState.swift; sourceTree = ""; }; + 6BCCCBFAEC5AC32DCB17C0E0 /* G6OnboardingViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6OnboardingViews.swift; path = Views/G6OnboardingViews.swift; sourceTree = ""; }; + 6D5C11477C372E423D1838A5 /* G6ApplySensorViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ApplySensorViews.swift; path = Views/G6ApplySensorViews.swift; sourceTree = ""; }; + 6ED9872057F9AB73CCE989CF /* TimeInterval.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TimeInterval.swift; sourceTree = ""; }; + 7F8E7A171900BF409A482908 /* G6PackageScannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6PackageScannerView.swift; path = Views/G6PackageScannerView.swift; sourceTree = ""; }; + 8B421AE8197339D306979939 /* G6SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8C69491E1F3F7F01BACF348A /* Localizable.xcstrings */ = {isa = PBXFileReference; includeInIndex = 1; path = Localizable.xcstrings; sourceTree = ""; }; + 93F66577CA895DC0539B94DB /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A15C3F478B37D28F487EA970 /* G6SensorKitPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SensorKitPlugin.swift; sourceTree = ""; }; + A1A4A5B6A02A6007C52230EE /* G6SensorKitPlugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G6SensorKitPlugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AAFC51720925E26FFF9526D8 /* G6Alert.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6Alert.swift; sourceTree = ""; }; + ABB63314B843491AD67FAFB8 /* G6DetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6DetailViews.swift; path = Views/G6DetailViews.swift; sourceTree = ""; }; + AFB4FF079E0E059B0A2514C4 /* G6SimulatedSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6SimulatedSession.swift; sourceTree = ""; }; + B9DBE6B77523AE6CF47FE179 /* G6ShareCredentialStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G6ShareCredentialStore.swift; sourceTree = ""; }; + BC82866E887A9C466610864B /* G6Graphics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6Graphics.swift; path = Views/G6Graphics.swift; sourceTree = ""; }; + C199CCC759EB31B7459FAF02 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + D20A6B1AD620BB43E52CA138 /* G6ActivityViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ActivityViewController.swift; path = Views/G6ActivityViewController.swift; sourceTree = ""; }; + DAF739DBBA35C93627B2A12C /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DB76778EA2C3FE465AE8FC8F /* G6SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6SettingsView.swift; path = Views/G6SettingsView.swift; sourceTree = ""; }; + E067B8007DAFB29BE2F03F48 /* HKUnit.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + E9773F6BB69F46104A715B09 /* G6ReadingDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = G6ReadingDetailView.swift; path = Views/G6ReadingDetailView.swift; sourceTree = ""; }; + F95502372EC14A32117B5F92 /* 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; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 08CFF0A2C0AF7A01359571D3 /* Frameworks */ = { + 29459DDF79C2F6E84E026198 /* 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 */, + 332BA57D80F0D8A629C0881A /* Foundation.framework in Frameworks */, + 3B2B08D9C8ACC49731771581 /* LoopKit.framework in Frameworks */, + F5B695198564172CA054D830 /* LoopKitUI.framework in Frameworks */, + 84E98310287C4DAF8D376F5C /* G6SensorKit.framework in Frameworks */, + 068F65E11AF1419388CBD953 /* G6SensorKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 4BBD731E15A4EBB02214C814 /* Frameworks */ = { + 7892D299DD1C4456611A5D26 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 60CA6A4323498F0AA5733921 /* Foundation.framework in Frameworks */, - E733B5AF9AC6DDC38B92A330 /* LoopKit.framework in Frameworks */, + CF81E72F011FD1971AA15AA2 /* Foundation.framework in Frameworks */, + 2E9270FAE984E3B653790956 /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 7034F2C68438154861EB8766 /* Frameworks */ = { + CC4E9DF835B45B2D2994E5A0 /* 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 */, + 9068DD5A9EB5430807D21AE5 /* Foundation.framework in Frameworks */, + 14589281DC7760165D5590E3 /* LoopKit.framework in Frameworks */, + 7A17394DCB757B79A35A9401 /* LoopKitUI.framework in Frameworks */, + 7B236774AE21607067257858 /* G6SensorKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0B2EBDB688CC9F72D51B8750 /* Products */ = { + 35E638A87A48A6CBC246BF6B /* Products */ = { isa = PBXGroup; children = ( - F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */, - 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */, - F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */, + 8B421AE8197339D306979939 /* G6SensorKit.framework */, + 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */, + A1A4A5B6A02A6007C52230EE /* G6SensorKitPlugin.framework */, ); name = Products; sourceTree = ""; }; - 1DCBFFA30E3C8A0058AA3F9E /* Common */ = { + 7CAD88E09B3DB8C98B60678D /* iOS */ = { isa = PBXGroup; children = ( - 89D79C9A64B80B4CC5B3DE78 /* HKUnit.swift */, - 48E317B6199E7FDA3530EB43 /* LocalizedString.swift */, - 2737F7376B1AEBD38191DE68 /* Locked.swift */, - 253838547314B57604F7AD40 /* OSLog.swift */, - BEFF2008F7747472D563331E /* TimeInterval.swift */, - B534C9F9F094DA2E641E1F56 /* HKUnit.swift */, + F95502372EC14A32117B5F92 /* Foundation.framework */, ); - name = Common; - path = Common; + name = iOS; sourceTree = ""; }; - 473EA52459AC5B5F384D87B9 /* iOS */ = { + 82F360FB55C90EC211EF8CA1 /* Common */ = { isa = PBXGroup; children = ( - F64DEB4B031D1C5CFE809317 /* Foundation.framework */, + C199CCC759EB31B7459FAF02 /* HKUnit.swift */, + 54CADE8EBD6A8DBD34E5F08E /* LocalizedString.swift */, + 3695593AE5C331A21B37C828 /* Locked.swift */, + 3B2202DFC274BBEC075ADF5E /* OSLog.swift */, + 6ED9872057F9AB73CCE989CF /* TimeInterval.swift */, + E067B8007DAFB29BE2F03F48 /* HKUnit.swift */, ); - name = iOS; + name = Common; + path = Common; sourceTree = ""; }; - 96B57708CA5A86D6435C7B27 /* Frameworks */ = { + B59CE774003B80A203AAC25E /* G6SensorKitUI */ = { isa = PBXGroup; children = ( - F8F0591ED59D9FDBBE9B979F /* LoopKit.framework */, - 73742E0944319E3A7E474AB9 /* LoopKitUI.framework */, - 473EA52459AC5B5F384D87B9 /* iOS */, + 3C2C05844010D28B27D4E02D /* G6CGMManager+UI.swift */, + 60451EC9CB88AAED72C7B4C0 /* G6UICoordinator.swift */, + 4847E689C846CC527559717B /* LocalizedString.swift */, + D20A6B1AD620BB43E52CA138 /* G6ActivityViewController.swift */, + 6D5C11477C372E423D1838A5 /* G6ApplySensorViews.swift */, + 14F99764DE6442A33728920B /* G6CalibrationGuideView.swift */, + ABB63314B843491AD67FAFB8 /* G6DetailViews.swift */, + BC82866E887A9C466610864B /* G6Graphics.swift */, + 6BCCCBFAEC5AC32DCB17C0E0 /* G6OnboardingViews.swift */, + 7F8E7A171900BF409A482908 /* G6PackageScannerView.swift */, + E9773F6BB69F46104A715B09 /* G6ReadingDetailView.swift */, + DB76778EA2C3FE465AE8FC8F /* G6SettingsView.swift */, + 8C69491E1F3F7F01BACF348A /* Localizable.xcstrings */, + DAF739DBBA35C93627B2A12C /* Assets.xcassets */, ); - name = Frameworks; + name = G6SensorKitUI; + path = G6SensorKitUI; sourceTree = ""; }; - BC0A237992092A7C2BE9B0C1 /* G6SensorKit */ = { + BF936AEBB1ADE9BD907B2665 /* G6SensorKit */ = { isa = PBXGroup; children = ( - 03906F90369E7A2BF4DED672 /* G6Alert.swift */, - B58664EFC566ECB3D78CCFAF /* G6AlertEvaluator.swift */, - 6ED4B45A9BCAFD7FD14DE50A /* G6CGMManager.swift */, - ADFDEDB952EC717B78C87578 /* G6CGMManagerState.swift */, - 8C22FE5054AF7D52948DDD28 /* G6ShareCredentialStore.swift */, - 74783BF907EE99207B30B578 /* G6SimulatedSession.swift */, - BE9888BF3A30CE450E970741 /* Localizable.xcstrings */, + AAFC51720925E26FFF9526D8 /* G6Alert.swift */, + 5BFF9D322E96EE881F0910FD /* G6AlertEvaluator.swift */, + 2AB56C95B7CD81513D98B212 /* G6CGMManager.swift */, + 650CB121B3A64293DDF66491 /* G6CGMManagerState.swift */, + B9DBE6B77523AE6CF47FE179 /* G6ShareCredentialStore.swift */, + AFB4FF079E0E059B0A2514C4 /* G6SimulatedSession.swift */, + 2803D463F94F749B9B4C4480 /* Localizable.xcstrings */, ); name = G6SensorKit; path = G6SensorKit; sourceTree = ""; }; - C15E860FAD47DBE3D41E4EC3 /* G6SensorKitPlugin */ = { + C8A84B9B66E84F3EA74CEF1D /* Frameworks */ = { isa = PBXGroup; children = ( - 7985708466B1C12CA524C462 /* G6SensorKitPlugin.swift */, + 1B0799EB2EDF3A7864F7C5C2 /* LoopKit.framework */, + 93F66577CA895DC0539B94DB /* LoopKitUI.framework */, + 7CAD88E09B3DB8C98B60678D /* iOS */, ); - name = G6SensorKitPlugin; - path = G6SensorKitPlugin; + name = Frameworks; sourceTree = ""; }; - CC50F2BDBEBFB7D6BD059422 /* G6SensorKitUI */ = { + CC1502118BD5CBA5F06AC98B /* G6SensorKitPlugin */ = { 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 */, + A15C3F478B37D28F487EA970 /* G6SensorKitPlugin.swift */, ); - name = G6SensorKitUI; - path = G6SensorKitUI; + name = G6SensorKitPlugin; + path = G6SensorKitPlugin; sourceTree = ""; }; - FA22E5F750CC96151439CB10 = { + D96C91ED9D8FC6BFCEDD36AC = { isa = PBXGroup; children = ( - BC0A237992092A7C2BE9B0C1 /* G6SensorKit */, - 1DCBFFA30E3C8A0058AA3F9E /* Common */, - CC50F2BDBEBFB7D6BD059422 /* G6SensorKitUI */, - C15E860FAD47DBE3D41E4EC3 /* G6SensorKitPlugin */, - 0B2EBDB688CC9F72D51B8750 /* Products */, - 96B57708CA5A86D6435C7B27 /* Frameworks */, + BF936AEBB1ADE9BD907B2665 /* G6SensorKit */, + 82F360FB55C90EC211EF8CA1 /* Common */, + B59CE774003B80A203AAC25E /* G6SensorKitUI */, + CC1502118BD5CBA5F06AC98B /* G6SensorKitPlugin */, + 35E638A87A48A6CBC246BF6B /* Products */, + C8A84B9B66E84F3EA74CEF1D /* Frameworks */, ); sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ - 46CF0A3AEA3068EC92793F50 /* Headers */ = { + 07EAC52738A519CDC1D4C8D4 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 8871D9EC41336D32DC93F0A1 /* Headers */ = { + 7ADBA84EA66B2BBAFD4A6288 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - D0DCBEE1011D380E0DAB9521 /* Headers */ = { + FF4AC7DA0EA20F2B20BE3902 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( @@ -286,14 +289,14 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ - 1B26058BD31E40741B0058A5 /* G6SensorKit */ = { + 215CD1147465A2E980A94D63 /* G6SensorKit */ = { isa = PBXNativeTarget; - buildConfigurationList = 3C02D248834BA3228A2A0985 /* Build configuration list for PBXNativeTarget "G6SensorKit" */; + buildConfigurationList = DD280BDB51D34244226439F4 /* Build configuration list for PBXNativeTarget "G6SensorKit" */; buildPhases = ( - D0DCBEE1011D380E0DAB9521 /* Headers */, - 8BC23E60728EBBC538BC019C /* Sources */, - 4BBD731E15A4EBB02214C814 /* Frameworks */, - 840346A90686729F6FA69249 /* Resources */, + 7ADBA84EA66B2BBAFD4A6288 /* Headers */, + CF5459A40A7FD204384A3827 /* Sources */, + 7892D299DD1C4456611A5D26 /* Frameworks */, + BBB2CD01CD25C8772A7D6484 /* Resources */, ); buildRules = ( ); @@ -301,62 +304,62 @@ ); name = G6SensorKit; packageProductDependencies = ( - 7B648B7A7143DAA0CE2F4FF9 /* G6SensorCore */, + 0EC90A7F507FA56DA0FAAC1D /* G6SensorCore */, ); productName = G6SensorKit; - productReference = F7B63810D31CCFC9F7F186D2 /* G6SensorKit.framework */; + productReference = 8B421AE8197339D306979939 /* G6SensorKit.framework */; productType = "com.apple.product-type.framework"; }; - 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */ = { + 8F3461A960A54D2320B21FE4 /* G6SensorKitUI */ = { isa = PBXNativeTarget; - buildConfigurationList = 0871B09F7297CFA1E6F3ADCB /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */; + buildConfigurationList = 072C8C59C81653E06FA00EB4 /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */; buildPhases = ( - 8871D9EC41336D32DC93F0A1 /* Headers */, - 488FB4793D06FDDE73E64813 /* Sources */, - 7034F2C68438154861EB8766 /* Frameworks */, - FF4CE3D3A177620D8B97B7A2 /* Resources */, + 07EAC52738A519CDC1D4C8D4 /* Headers */, + 30F8F65AC6C7D52F848495B9 /* Sources */, + CC4E9DF835B45B2D2994E5A0 /* Frameworks */, + 6D20D7C6745342F8C65404B0 /* Resources */, ); buildRules = ( ); dependencies = ( - 17B55B286FE0A660013A668D /* PBXTargetDependency */, + 2B67866B45ABC4CCDF08A03B /* PBXTargetDependency */, ); name = G6SensorKitUI; productName = G6SensorKitUI; - productReference = 0E1FE9C6D076DA1E91289E83 /* G6SensorKitUI.framework */; + productReference = 9D531F0A53057A0235E7C192 /* G6SensorKitUI.framework */; productType = "com.apple.product-type.framework"; }; - EDCCBEADE99E54A732DD551F /* G6SensorKitPlugin */ = { + D09D95523AA824744122C1CB /* G6SensorKitPlugin */ = { isa = PBXNativeTarget; - buildConfigurationList = 5C9202DE59969D7F24D99477 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */; + buildConfigurationList = FCDA3AF29E0740002157A96C /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */; buildPhases = ( - 46CF0A3AEA3068EC92793F50 /* Headers */, - A4FF0114F6295250522E88B0 /* Sources */, - 08CFF0A2C0AF7A01359571D3 /* Frameworks */, - BD1DA6A29C055EAE3F534D12 /* Resources */, - 4FFBC74F2EB40DC4B5ED7514 /* Embed Frameworks */, + FF4AC7DA0EA20F2B20BE3902 /* Headers */, + D91BC325094ACAC7D108B6A0 /* Sources */, + 29459DDF79C2F6E84E026198 /* Frameworks */, + BE615E0FBD1840B4E6C34D02 /* Resources */, + D0449C47C05DB7A8F1FC65C4 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( - 07F9FA8A5643278CD0C1E3DC /* PBXTargetDependency */, - 53F05EC0188A4EBC5A8F5A4F /* PBXTargetDependency */, + 627FBE52365C3C03931C7886 /* PBXTargetDependency */, + F6FFF5B09BC08B51699968C7 /* PBXTargetDependency */, ); name = G6SensorKitPlugin; productName = G6SensorKitPlugin; - productReference = F566E5E3758DE9D2DBE17607 /* G6SensorKitPlugin.framework */; + productReference = A1A4A5B6A02A6007C52230EE /* G6SensorKitPlugin.framework */; productType = "com.apple.product-type.framework"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - 9254155A3D36B6309735ED9B /* Project object */ = { + 05E8929C0E445F2264A8A045 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; }; - buildConfigurationList = 1F831DF837A28B0445B1871F /* Build configuration list for PBXProject "G6SensorKit" */; + buildConfigurationList = 8B609048B49027655DD83E61 /* Build configuration list for PBXProject "G6SensorKit" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; @@ -364,121 +367,122 @@ en, Base, ); - mainGroup = FA22E5F750CC96151439CB10; + mainGroup = D96C91ED9D8FC6BFCEDD36AC; minimizedProjectReferenceProxies = 0; packageReferences = ( - 17A8E591A82F95E6476AA1C9 /* XCLocalSwiftPackageReference "." */, + 222FDCD3CB726F70DF5E5FEA /* XCLocalSwiftPackageReference "." */, ); - preferredProjectObjectVersion = 77; - productRefGroup = 0B2EBDB688CC9F72D51B8750 /* Products */; + preferredProjectObjectVersion = 100; + productRefGroup = 35E638A87A48A6CBC246BF6B /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 1B26058BD31E40741B0058A5 /* G6SensorKit */, - 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */, - EDCCBEADE99E54A732DD551F /* G6SensorKitPlugin */, + 215CD1147465A2E980A94D63 /* G6SensorKit */, + 8F3461A960A54D2320B21FE4 /* G6SensorKitUI */, + D09D95523AA824744122C1CB /* G6SensorKitPlugin */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 840346A90686729F6FA69249 /* Resources */ = { + 6D20D7C6745342F8C65404B0 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3B345FE82B0B128320B404C0 /* Localizable.xcstrings in Resources */, + 2921FB176CBA1E3FACF8C998 /* Localizable.xcstrings in Resources */, + 226AE1C008E3432034691E58 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - BD1DA6A29C055EAE3F534D12 /* Resources */ = { + BBB2CD01CD25C8772A7D6484 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2C77CCC0A4FC873D451B1D9C /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - FF4CE3D3A177620D8B97B7A2 /* Resources */ = { + BE615E0FBD1840B4E6C34D02 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 04A2B67E2547E628203F1358 /* Localizable.xcstrings in Resources */, - CDBF362147EF86005600539C /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 488FB4793D06FDDE73E64813 /* Sources */ = { + 30F8F65AC6C7D52F848495B9 /* 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 */, + 39603B3649182CA37CE8237A /* G6CGMManager+UI.swift in Sources */, + 77CD64A55933B10835781892 /* G6UICoordinator.swift in Sources */, + 405A216F8D1752F1421D4887 /* LocalizedString.swift in Sources */, + 6C6A5725B781F8EC0283A2E3 /* G6ActivityViewController.swift in Sources */, + 85D8C77F66A1FF1AF57A8F17 /* G6ApplySensorViews.swift in Sources */, + 934EFF804E6452CD19F40C04 /* G6CalibrationGuideView.swift in Sources */, + D48D98C13312034AB8C11BA8 /* G6DetailViews.swift in Sources */, + 3A7D53148400D95D5EBE9B20 /* G6Graphics.swift in Sources */, + DA3B54D9184C892B5F9A7B96 /* G6OnboardingViews.swift in Sources */, + E5928066DDD984FCE765E888 /* G6PackageScannerView.swift in Sources */, + F3BF96B43DC44EAB997C9556 /* G6ReadingDetailView.swift in Sources */, + 9D23800E9E278C62602AAF06 /* G6SettingsView.swift in Sources */, + 8587D2CC46C9C9CB94B2D3FA /* HKUnit.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 8BC23E60728EBBC538BC019C /* Sources */ = { + CF5459A40A7FD204384A3827 /* 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 */, + 2A9D142A81AE191360B058B0 /* G6Alert.swift in Sources */, + 9068DC2E9303B2620EC57AF2 /* G6AlertEvaluator.swift in Sources */, + 5623B9849DDAC97251E7B413 /* G6CGMManager.swift in Sources */, + 3A518597A56D377383B5D384 /* G6CGMManagerState.swift in Sources */, + 83FDE18A177B5C10B0504485 /* G6ShareCredentialStore.swift in Sources */, + 4159FE6F9D1C67AA7D355705 /* G6SimulatedSession.swift in Sources */, + 3D85A309CDAA70C948DB7D06 /* HKUnit.swift in Sources */, + 25B13BDAFD609EC61E01ED42 /* LocalizedString.swift in Sources */, + 1BFC8B0012423024BCEE84C4 /* Locked.swift in Sources */, + EE9148D1635A031059CB0474 /* OSLog.swift in Sources */, + ABC18A66F55CAC750FAAA781 /* TimeInterval.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - A4FF0114F6295250522E88B0 /* Sources */ = { + D91BC325094ACAC7D108B6A0 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F605B5DD6F6B98C7BA18BE26 /* G6SensorKitPlugin.swift in Sources */, + 81CC3E50E4370774F5954A78 /* G6SensorKitPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 07F9FA8A5643278CD0C1E3DC /* PBXTargetDependency */ = { + 2B67866B45ABC4CCDF08A03B /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKit; - target = 1B26058BD31E40741B0058A5 /* G6SensorKit */; - targetProxy = 5DCC41D3B8AE0AF30CAAD2E3 /* PBXContainerItemProxy */; + target = 215CD1147465A2E980A94D63 /* G6SensorKit */; + targetProxy = 8C5FBFBE5966E33ED892663C /* PBXContainerItemProxy */; }; - 17B55B286FE0A660013A668D /* PBXTargetDependency */ = { + 627FBE52365C3C03931C7886 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKit; - target = 1B26058BD31E40741B0058A5 /* G6SensorKit */; - targetProxy = CC0AE83086E9B970A7244CCE /* PBXContainerItemProxy */; + target = 215CD1147465A2E980A94D63 /* G6SensorKit */; + targetProxy = AB91E4CF703C2153542F72B0 /* PBXContainerItemProxy */; }; - 53F05EC0188A4EBC5A8F5A4F /* PBXTargetDependency */ = { + F6FFF5B09BC08B51699968C7 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = G6SensorKitUI; - target = 3ADDB0CD055A33C01C7B54FE /* G6SensorKitUI */; - targetProxy = 393AD5FC1C4D642198DBF3BA /* PBXContainerItemProxy */; + target = 8F3461A960A54D2320B21FE4 /* G6SensorKitUI */; + targetProxy = 1CA7CA75216CC94C77001C8A /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 000A188CB4AC3C534F7DEDA5 /* Debug */ = { + 3A87E12D403C48B6C4E16BCA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -498,7 +502,7 @@ 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.G6SensorKitPlugin; @@ -508,13 +512,14 @@ 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 */ = { + 3B66BBA892E68EDF726B4480 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -548,29 +553,35 @@ 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; + DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.9; - VALIDATE_PRODUCT = YES; }; - name = Release; + name = Debug; }; - 1AE1D80C0367AD875A928588 /* Release */ = { + 6965477C499968CA83551725 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -587,87 +598,24 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKitUI/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.G6SensorKitUI; + 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 = ""; }; - name = Release; - }; - 222C333070D0F8651A5DDC09 /* 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++"; - 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; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - 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 = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.9; - }; name = Debug; }; - 98F6F8109B911D276CB9A52E /* Debug */ = { + 6EC800F9252B079EBE43E8D0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -684,24 +632,25 @@ "$(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 = Debug; + name = Release; }; - D9853CD9CF2489E939E9B1B7 /* Debug */ = { + 9342504C39FA6A5C36B3F153 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -718,24 +667,25 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKitUI/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.G6SensorKitUI; + 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 = ""; }; - name = Debug; + name = Release; }; - F5F1150AA306D577B24CACB2 /* Release */ = { + C2EF29AC766E13A496E2D70A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; @@ -743,7 +693,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"; @@ -752,25 +702,82 @@ "$(inherited)", "$(BUILT_PRODUCTS_DIR)", ); - INFOPLIST_FILE = G6SensorKit/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.G6SensorKit; + PRODUCT_BUNDLE_IDENTIFIER = org.nightscout.G6SensorKitPlugin; 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; + }; + E1A1853C1186E503AB2BC172 /* Release */ = { + 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++"; + 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 = 17.6; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + VALIDATE_PRODUCT = YES; }; name = Release; }; - FC3AF832A0B14A9957D5966C /* Release */ = { + F3ADA5588B68CDB0DC02C9F6 /* 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 = 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 = Release; + name = Debug; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0871B09F7297CFA1E6F3ADCB /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */ = { + 072C8C59C81653E06FA00EB4 /* Build configuration list for PBXNativeTarget "G6SensorKitUI" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1AE1D80C0367AD875A928588 /* Release */, - D9853CD9CF2489E939E9B1B7 /* Debug */, + 6EC800F9252B079EBE43E8D0 /* Release */, + F3ADA5588B68CDB0DC02C9F6 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 1F831DF837A28B0445B1871F /* Build configuration list for PBXProject "G6SensorKit" */ = { + 8B609048B49027655DD83E61 /* Build configuration list for PBXProject "G6SensorKit" */ = { isa = XCConfigurationList; buildConfigurations = ( - 222C333070D0F8651A5DDC09 /* Debug */, - 0A0532B628C24522B116C2FD /* Release */, + 3B66BBA892E68EDF726B4480 /* Debug */, + E1A1853C1186E503AB2BC172 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 3C02D248834BA3228A2A0985 /* Build configuration list for PBXNativeTarget "G6SensorKit" */ = { + DD280BDB51D34244226439F4 /* Build configuration list for PBXNativeTarget "G6SensorKit" */ = { isa = XCConfigurationList; buildConfigurations = ( - F5F1150AA306D577B24CACB2 /* Release */, - 98F6F8109B911D276CB9A52E /* Debug */, + 9342504C39FA6A5C36B3F153 /* Release */, + 6965477C499968CA83551725 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5C9202DE59969D7F24D99477 /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */ = { + FCDA3AF29E0740002157A96C /* Build configuration list for PBXNativeTarget "G6SensorKitPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( - FC3AF832A0B14A9957D5966C /* Release */, - 000A188CB4AC3C534F7DEDA5 /* Debug */, + 3A87E12D403C48B6C4E16BCA /* Release */, + C2EF29AC766E13A496E2D70A /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -849,18 +853,18 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 17A8E591A82F95E6476AA1C9 /* XCLocalSwiftPackageReference "." */ = { + 222FDCD3CB726F70DF5E5FEA /* XCLocalSwiftPackageReference "." */ = { isa = XCLocalSwiftPackageReference; relativePath = .; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 7B648B7A7143DAA0CE2F4FF9 /* G6SensorCore */ = { + 0EC90A7F507FA56DA0FAAC1D /* G6SensorCore */ = { isa = XCSwiftPackageProductDependency; productName = G6SensorCore; }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = 9254155A3D36B6309735ED9B /* Project object */; + rootObject = 05E8929C0E445F2264A8A045 /* Project object */; } diff --git a/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme b/G6SensorKit.xcodeproj/xcshareddata/xcschemes/G6SensorKit.xcscheme index 6c3c107..2a070a1 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..aacd34c 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..1c91f21 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/G6CGMManager.swift b/G6SensorKit/G6CGMManager.swift index 93d4516..48707f7 100644 --- a/G6SensorKit/G6CGMManager.swift +++ b/G6SensorKit/G6CGMManager.swift @@ -69,9 +69,7 @@ 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 var lastBatteryReadDate: Date? + private let lockedCommandQueue: Locked = Locked(CommandQueue()) /// Response code from a session start awaiting confirmation. The reply /// alone does not say whether a session began, so it is held until the @@ -134,7 +132,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 +149,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() } @@ -304,11 +302,8 @@ public final class G6CGMManager: CGMManager { // 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) } if superseded > 0 { @@ -316,10 +311,7 @@ public final class G6CGMManager: CGMManager { } mutateState { state in - state.pendingCommands.removeAll { raw in - Command(rawValue: raw).map { $0.supersededBy(command) } ?? true - } - state.pendingCommands.append(command.rawValue) + state.pendingCommands = self.lockedCommandQueue.value.rawValues // Reflect the entered code straight away. Waiting until the // transmitter answered meant the screen said "Not used" for as @@ -332,6 +324,38 @@ 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() } + if case .stopSensor = command, state.sensorStartDate != nil { + let sensorEnded = PersistedCgmEvent( + date: Date(), + type: .sensorEnd, + deviceIdentifier: state.transmitterID, + failureMessage: LocalizedString("Stopped by user", comment: "Reason recorded when a session ends because the user stopped it") + ) + delegate.notify { delegate in + delegate?.cgmManager(self, hasNew: [sensorEnded]) + } + } + 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 +407,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 +431,7 @@ 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() } raisedAlerts = [] mutateState { state in @@ -530,9 +554,9 @@ public final class G6CGMManager: CGMManager { } } - if let last = lastBatteryReadDate, Date().timeIntervalSince(last) < Self.batteryReadInterval { - // Battery was read recently. - } else { + let batteryReadRecently = state.lastBatteryReadDate + .map { Date().timeIntervalSince($0) < Self.batteryReadInterval } ?? false + if !batteryReadRecently { session?.shouldReadBattery = true } @@ -632,7 +656,6 @@ extension G6CGMManager: TransmitterSessionDelegate { } public func transmitterSession(_ session: TransmitterSession, didReadBattery message: BatteryStatusRxMessage) { - lastBatteryReadDate = Date() log.default("Battery: A %d, B %d, resist %d", Int(message.voltageA), Int(message.voltageB), Int(message.resist)) mutateState { state in @@ -814,27 +837,10 @@ extension G6CGMManager: TransmitterSessionDelegate { } private func glucoseTrend(for glucose: Glucose) -> GlucoseTrend? { - guard let rate = glucose.trendRateMgDLPerMinute else { + guard let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: glucose.trendRateMgDLPerMinute) else { return nil } - - // Standard Dexcom trend arrow thresholds in (mg/dL)/min. - switch rate { - case ..<(-3): - return .downDownDown - case ..<(-2): - return .downDown - case ..<(-1): - return .down - case ..<1: - return .flat - case ..<2: - return .up - case ..<3: - return .upUp - default: - return .upUpUp - } + return GlucoseTrend(rawValue: arrow.rawValue) } } @@ -846,36 +852,17 @@ 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 - } + lockedCommandQueue.mutate { queue in + (next, dropped) = queue.dequeue() } for command in dropped { log.default("Dropping stale command: %{public}@", String(describing: command)) - mutateState { state in - if !state.pendingCommands.isEmpty { - state.pendingCommands.removeFirst() - } - } } - if next != nil { + if next != nil || !dropped.isEmpty { mutateState { state in - if !state.pendingCommands.isEmpty { - state.pendingCommands.removeFirst() - } + state.pendingCommands = self.lockedCommandQueue.value.rawValues } } @@ -911,6 +898,17 @@ extension G6CGMManager: TransmitterCommandSource { state.lastSessionStartFailure = nil } case .stopSensor: + if state.sensorStartDate != nil { + let sensorEnded = PersistedCgmEvent( + date: Date(), + type: .sensorEnd, + deviceIdentifier: state.transmitterID, + failureMessage: LocalizedString("Stopped by user", comment: "Reason recorded when a session ends because the user stopped it") + ) + delegate.notify { delegate in + delegate?.cgmManager(self, hasNew: [sensorEnded]) + } + } mutateState { state in state.sensorStartDate = nil state.sensorCode = nil @@ -933,18 +931,10 @@ struct G6GlucoseDisplay: GlucoseDisplayable { } var trendType: GlucoseTrend? { - guard let rate = reading.trendRateMgDLPerMinute else { + guard let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: reading.trendRateMgDLPerMinute) else { return nil } - switch rate { - case ..<(-3): return .downDownDown - case ..<(-2): return .downDown - case ..<(-1): return .down - case ..<1: return .flat - case ..<2: return .up - case ..<3: return .upUp - default: return .upUpUp - } + return GlucoseTrend(rawValue: arrow.rawValue) } var trendRate: HKQuantity? { diff --git a/G6SensorKit/G6CGMManagerState.swift b/G6SensorKit/G6CGMManagerState.swift index 40b6039..d96eb22 100644 --- a/G6SensorKit/G6CGMManagerState.swift +++ b/G6SensorKit/G6CGMManagerState.swift @@ -308,7 +308,14 @@ public struct G6CGMManagerState: RawRepresentable { return sensorStartDate?.addingTimeInterval(sensorLife) } + /// Unknown until the first version read: the fallback constants in + /// `transmitterLifetime` are guesses (a 180-day Anubis would alert as + /// "expired" against the stock 90 days), so no expiry is reported before + /// the transmitter's own value is in. public var transmitterExpirationDate: Date? { + guard transmitterExpiryInDays != nil else { + return nil + } return transmitterStartDate?.addingTimeInterval(transmitterLifetime) } @@ -338,11 +345,9 @@ public struct G6CGMManagerState: RawRepresentable { /// The device model implied by the transmitter ID prefix. G6 transmitters /// use `8…`; Dexcom ONE uses `5…` or `C…`. public var deviceModel: String { - switch transmitterID.first { - case "5", "C", "c": - return "Dexcom ONE" - default: - return "Dexcom G6" + switch G6TransmitterModel(transmitterID: transmitterID) { + case .g6: return "Dexcom G6" + case .one: return "Dexcom ONE" } } } diff --git a/G6SensorKit/G6SimulatedSession.swift b/G6SensorKit/G6SimulatedSession.swift index cc886c3..30c4aac 100644 --- a/G6SensorKit/G6SimulatedSession.swift +++ b/G6SensorKit/G6SimulatedSession.swift @@ -43,7 +43,9 @@ extension G6CGMManager { // warming up. state.transmitterStartDate = now.addingTimeInterval(-.hours(24 * 12)) state.sensorStartDate = now.addingTimeInterval(-(state.warmupPeriod - Self.simulatedWarmupRemaining)) - state.transmitterExpiryInDays = 90 + // 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 +66,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..8d6f1b6 100644 --- a/G6SensorKit/Localizable.xcstrings +++ b/G6SensorKit/Localizable.xcstrings @@ -120,6 +120,10 @@ "mg/dL": { "extractionState": "manual", "comment": "The short unit display string for milligrams of glucose per deciliter" + }, + "Stopped by user": { + "extractionState": "manual", + "comment": "Reason recorded when a session ends because the user stopped it" } }, "version": "1.0" diff --git a/G6SensorKitUI/Localizable.xcstrings b/G6SensorKitUI/Localizable.xcstrings index f218d5c..07fa8e4 100644 --- a/G6SensorKitUI/Localizable.xcstrings +++ b/G6SensorKitUI/Localizable.xcstrings @@ -1,838 +1,936 @@ { "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" - }, - "Slide the transmitter tab into the slot at the narrow end of the holder.": { - "extractionState": "manual", - "comment": "Attach step 3 body" + "comment": "Attach step 1 note", + "extractionState": "manual" }, - "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" + }, + "Lift the applicator away.": { + "comment": "Insert step 9 body", + "extractionState": "manual" + }, + "Looking for your transmitter…": { + "comment": "Pairing status: searching", + "extractionState": "manual" }, "Model": { - "extractionState": "manual", - "comment": "Transmitter detail label for model" + "comment": "Transmitter detail label for model", + "extractionState": "manual" }, - "Firmware": { - "extractionState": "manual", - "comment": "Transmitter detail label for firmware" + "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" }, - "Unknown": { - "extractionState": "manual", - "comment": "Placeholder for unknown value" + "Next": { + "comment": "Next button", + "extractionState": "manual" }, - "First used": { - "extractionState": "manual", - "comment": "Transmitter detail label for activation date" + "No": { + "comment": "Negative", + "extractionState": "manual" }, - "Expected to expire": { - "extractionState": "manual", - "comment": "Transmitter detail label for expiry" + "No code on your sensor?": { + "comment": "Sensor code callout title", + "extractionState": "manual" }, - "Reported lifetime": { - "extractionState": "manual", - "comment": "Transmitter detail label for reported lifetime" + "No readings — replace the sensor.": { + "comment": "Placeholder when the sensor has failed", + "extractionState": "manual" }, - "%d days": { - "extractionState": "manual", - "comment": "Number of days (1: day count)" + "No sensor": { + "comment": "Why calibration is unavailable without a session", + "extractionState": "manual" }, - "Lifetime": { - "extractionState": "manual", - "comment": "Transmitter details section header for lifetime" + "No Sensor": { + "comment": "Status highlight when no session is running", + "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 Signal": { + "comment": "Session phase: signal loss", + "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" + "Not available": { + "comment": "Value when the transmitter reports no trend", + "extractionState": "manual" }, - "Session length: %d days": { - "extractionState": "manual", - "comment": "Session length value (1: day count)" + "Not used": { + "comment": "Value when a session runs without a sensor code", + "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" + "OK": { + "comment": "Algorithm state: ok", + "extractionState": "manual" }, - "Upload to Dexcom Share": { - "extractionState": "manual", - "comment": "Toggle label for Share upload" + "On the transmitter box, next to the barcode.": { + "comment": "Transmitter ID: location on the box", + "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" + "Open Settings": { + "comment": "Open Settings button", + "extractionState": "manual" }, - "Username": { - "extractionState": "manual", - "comment": "Share account username field" + "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" }, - "Password": { - "extractionState": "manual", - "comment": "Share account password field" + "Or on the back of the transmitter itself.": { + "comment": "Transmitter ID: location on the transmitter", + "extractionState": "manual" }, - "Save Credentials": { - "extractionState": "manual", - "comment": "Button to save Share credentials" + "Pair a new transmitter?": { + "comment": "Confirmation title for pairing a new transmitter", + "extractionState": "manual" }, - "Dexcom Account": { - "extractionState": "manual", - "comment": "Section header for Share account" + "Pair New Transmitter": { + "comment": "Row to pair a different 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" + "Pairing": { + "comment": "Navigation title for pairing screen", + "extractionState": "manual" }, - "Upload turned off and saved credentials removed.": { - "extractionState": "manual", - "comment": "Status message after disabling Share upload" + "Password": { + "comment": "Share account password field", + "extractionState": "manual" }, - "Credentials saved to the Keychain.": { - "extractionState": "manual", - "comment": "Status message after saving Share credentials" + "Place the applicator flat against your skin — horizontally, not upright — and press down firmly.": { + "comment": "Insert step 6 body", + "extractionState": "manual" }, - "Could not save your credentials. Try again.": { - "extractionState": "manual", - "comment": "Status message when saving Share credentials fails" + "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" }, - "Warming Up": { - "extractionState": "manual", - "comment": "Session phase: warm-up" + "Press the wide end of the transmitter until it clicks into the holder.": { + "comment": "Attach step 4 body", + "extractionState": "manual" }, - "Active": { - "extractionState": "manual", - "comment": "Session phase: active" + "Pull off both adhesive labels.": { + "comment": "Insert step 5 body", + "extractionState": "manual" }, - "Ending Soon": { - "extractionState": "manual", - "comment": "Session phase: expiring soon" + "Push and release the button to insert the sensor.": { + "comment": "Insert step 8 body", + "extractionState": "manual" }, - "Session Ended": { - "extractionState": "manual", - "comment": "Session phase: expired" + "Reading": { + "comment": "Reading detail section header", + "extractionState": "manual" }, - "No Signal": { - "extractionState": "manual", - "comment": "Session phase: signal loss" + "Readings will appear as they arrive from the transmitter, about every 5 minutes.": { + "comment": "Explanation shown once warm-up has completed", + "extractionState": "manual" }, - "Dexcom G6": { - "extractionState": "manual", - "comment": "Introduction screen product title" + "Remove CGM": { + "comment": "Confirm CGM removal", + "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)" + "Remove CGM from App": { + "comment": "Button to delete the CGM manager", + "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 this CGM?": { + "comment": "Confirmation title for removing the CGM", + "extractionState": "manual" }, - "Enter your transmitter ID": { - "extractionState": "manual", - "comment": "Transmitter ID screen heading" + "Replace sensor": { + "comment": "Detail when the session is over", + "extractionState": "manual" }, - "On the transmitter box, next to the barcode.": { - "extractionState": "manual", - "comment": "Transmitter ID: location on the box" + "Reported lifetime": { + "comment": "Transmitter detail label for reported lifetime", + "extractionState": "manual" }, - "Or on the back of the transmitter itself.": { - "extractionState": "manual", - "comment": "Transmitter ID: location on the transmitter" + "Resistance": { + "comment": "Transmitter detail label for battery resistance", + "extractionState": "manual" }, - "6-character ID": { - "extractionState": "manual", - "comment": "Transmitter ID text field placeholder" + "Rub your fingers around the patch three times to secure it.": { + "comment": "Attach step 5 body", + "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" + "Save Credentials": { + "comment": "Button to save Share credentials", + "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" + "Scan package code": { + "comment": "Scan package barcode button", + "extractionState": "manual" }, - "Enter your sensor code": { - "extractionState": "manual", - "comment": "Sensor code screen heading" + "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" }, - "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" + "Send Calibration": { + "comment": "Calibration submit button", + "extractionState": "manual" }, - "4-digit code": { - "extractionState": "manual", - "comment": "Sensor code text field placeholder" + "Sensor": { + "comment": "Settings section: sensor", + "extractionState": "manual" }, - "No code on your sensor?": { - "extractionState": "manual", - "comment": "Sensor code callout title" + "Sensor code": { + "comment": "Metadata label for sensor code", + "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 Code": { + "comment": "Navigation title for sensor code entry", + "extractionState": "manual" }, - "Use This Code": { - "extractionState": "manual", - "comment": "Sensor code submit button" + "Sensor Expired": { + "comment": "Status highlight for expired sensor", + "extractionState": "manual" }, - "Start Without a Code": { - "extractionState": "manual", - "comment": "Button to start a session with no sensor code" + "Sensor failed": { + "comment": "Algorithm state: failed", + "extractionState": "manual" + }, + "Sensor Failed": { + "comment": "Status highlight for failed sensor", + "extractionState": "manual" }, - "Start without a sensor code?": { - "extractionState": "manual", - "comment": "Confirmation title for a code-less session" + "Sensor session found": { + "comment": "Pairing success label for an existing session", + "extractionState": "manual" }, - "Enter the Code": { - "extractionState": "manual", - "comment": "Return to entering the 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" }, - "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 started": { + "comment": "Metadata label for sensor start", + "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 status": { + "comment": "Reading detail label for algorithm state", + "extractionState": "manual" }, - "Connecting to your transmitter": { - "extractionState": "manual", - "comment": "Pairing screen heading" + "Sensor Warmup": { + "comment": "Status highlight during warm-up", + "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" + "Session Ended": { + "comment": "Session phase: expired", + "extractionState": "manual" }, - "Tap Pair on the iOS prompt": { - "extractionState": "manual", - "comment": "Pairing callout title while awaiting the prompt" + "Session ends": { + "comment": "Metadata label for session end", + "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 Length": { + "comment": "Navigation title for sensor life settings", + "extractionState": "manual" }, - "A pairing request will appear": { - "extractionState": "manual", - "comment": "Pairing callout title" + "Session length: %d days": { + "comment": "Session length value (1: day count)", + "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 stopped": { + "comment": "Algorithm state: stopped", + "extractionState": "manual" }, - "Sensor session found": { - "extractionState": "manual", - "comment": "Pairing success label for an existing session" + "Share Logs": { + "comment": "Row to export the log files", + "extractionState": "manual" }, - "Still looking": { - "extractionState": "manual", - "comment": "Pairing troubleshooting title" + "Signal Loss": { + "comment": "Status highlight for signal loss", + "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" + "Slide the transmitter tab into the slot at the narrow end of the holder.": { + "comment": "Attach step 3 body", + "extractionState": "manual" }, - "Share Logs": { - "extractionState": "manual", - "comment": "Row to export the log files" + "Source": { + "comment": "Reading detail section header for source", + "extractionState": "manual" }, - "Connected to your transmitter": { - "extractionState": "manual", - "comment": "Pairing status: connected" + "Start New Sensor": { + "comment": "Button to begin a new sensor session", + "extractionState": "manual" }, - "Waiting for you to accept pairing…": { - "extractionState": "manual", - "comment": "Pairing status: awaiting prompt" + "Start time unknown": { + "comment": "Detail when a session is running but its start is unknown", + "extractionState": "manual" }, - "Looking for your transmitter…": { - "extractionState": "manual", - "comment": "Pairing status: searching" + "Start Without a Code": { + "comment": "Button to start a session with no sensor code", + "extractionState": "manual" }, - "Your sensor is warming up": { - "extractionState": "manual", - "comment": "Warm-up screen heading" + "Start without a sensor code?": { + "comment": "Confirmation title for a code-less session", + "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)" + "STEP %d of 3": { + "comment": "Calibration step counter (1: step number)", + "extractionState": "manual" }, - "Do not dose on early readings": { - "extractionState": "manual", - "comment": "Warm-up callout title" + "STEP %d of 4": { + "comment": "Attach-transmitter step counter (1: step number)", + "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" + "STEP %d of 10": { + "comment": "Insert-sensor step counter (1: step number)", + "extractionState": "manual" }, - "Trend": { - "extractionState": "manual", - "comment": "Reading detail label for trend" + "Still looking": { + "comment": "Pairing troubleshooting title", + "extractionState": "manual" }, - "Sensor status": { - "extractionState": "manual", - "comment": "Reading detail label for algorithm state" + "Stop Sensor Session": { + "comment": "Button to stop the sensor session", + "extractionState": "manual" }, - "Display only": { - "extractionState": "manual", - "comment": "Reading detail label for display-only flag" + "Stop Session": { + "comment": "Confirm stop session", + "extractionState": "manual" }, - "Yes": { - "extractionState": "manual", - "comment": "Affirmative" + "Stop this sensor session?": { + "comment": "Confirmation title for stopping a session", + "extractionState": "manual" }, - "No": { - "extractionState": "manual", - "comment": "Negative" + "Take a blood sample from your fingertip and measure it with your meter.": { + "comment": "Calibration step 2 body", + "extractionState": "manual" }, - "Reading": { - "extractionState": "manual", - "comment": "Reading detail section header" + "Take out the applicator whose sensor code you entered, and peel off its cover.": { + "comment": "Insert step 4 body", + "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" + "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" }, - "Device": { - "extractionState": "manual", - "comment": "Reading detail label for device" + "Tap Pair on the iOS prompt": { + "comment": "Pairing callout title while awaiting the prompt", + "extractionState": "manual" }, - "Identifier": { - "extractionState": "manual", - "comment": "Reading detail label for sync identifier" + "Temperature": { + "comment": "Transmitter detail label for temperature", + "extractionState": "manual" }, - "Source": { - "extractionState": "manual", - "comment": "Reading detail section header for source" + "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" }, - "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" + "That doesn't look like a Dexcom package. Try again or type the ID manually.": { + "comment": "Scanned non-Dexcom barcode", + "extractionState": "manual" }, - "Latest Reading": { - "extractionState": "manual", - "comment": "Reading detail screen title" + "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" }, - "Not available": { - "extractionState": "manual", - "comment": "Value when the transmitter reports no trend" + "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" }, - "Sensor failed": { - "extractionState": "manual", - "comment": "Algorithm state: failed" + "The camera couldn't start. Try again or type the ID manually.": { + "comment": "Scanner failed to start", + "extractionState": "manual" }, - "Warming up": { - "extractionState": "manual", - "comment": "Algorithm state: warm-up" + "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" }, - "Session stopped": { - "extractionState": "manual", - "comment": "Algorithm state: stopped" + "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" }, - "Calibration requested": { - "extractionState": "manual", - "comment": "Algorithm state: needs calibration" + "The site must be completely dry or the adhesive will not stick.": { + "comment": "Insert step 2 note", + "extractionState": "manual" }, - "OK": { - "extractionState": "manual", - "comment": "Algorithm state: ok" + "This app": { + "comment": "Fallback when the host app name cannot be read", + "extractionState": "manual" }, - "Unusable (code %d)": { - "extractionState": "manual", - "comment": "Algorithm state: unusable with raw code" + "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" }, - "Start time unknown": { - "extractionState": "manual", - "comment": "Detail when a session is running but its start is unknown" + "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" }, - "%@ until ready": { - "extractionState": "manual", - "comment": "Warm-up remaining (1: duration)" + "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" }, - "Replace sensor": { - "extractionState": "manual", - "comment": "Detail when the session is over" + "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" }, - "%@ remaining": { - "extractionState": "manual", - "comment": "Session remaining (1: duration)" + "Thoroughly wash and dry your hands.": { + "comment": "Insert step 1 body", + "extractionState": "manual" }, - "—": { - "extractionState": "manual", - "comment": "Placeholder for missing value" + "Transmitter": { + "comment": "Navigation title for transmitter details", + "extractionState": "manual" }, - "Just now": { - "extractionState": "manual", - "comment": "Reading received less than a minute ago" + "Transmitter Details": { + "comment": "Row to transmitter details", + "extractionState": "manual" }, - "%d min ago": { - "extractionState": "manual", - "comment": "Minutes since last reading (1: minutes)" + "Transmitter Expired": { + "comment": "Status highlight when the transmitter is past end of life", + "extractionState": "manual" }, - "%dd %dh": { - "extractionState": "manual", - "comment": "Duration in days and hours" + "Transmitter ID": { + "comment": "Navigation title for transmitter ID entry", + "extractionState": "manual" }, - "%dh %dm": { - "extractionState": "manual", - "comment": "Duration in hours and minutes" + "Trend": { + "comment": "Reading detail label for trend", + "extractionState": "manual" }, - "%dm": { - "extractionState": "manual", - "comment": "Duration in minutes" + "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" }, - "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" + "Unknown": { + "comment": "Placeholder for unknown value", + "extractionState": "manual" }, - "Sensor": { - "extractionState": "manual", - "comment": "Settings section: sensor" + "Unusable (code %d)": { + "comment": "Algorithm state: unusable with raw code", + "extractionState": "manual" }, - "Last Reading": { - "extractionState": "manual", - "comment": "Settings section: last reading" + "Upload to Dexcom Share": { + "comment": "Toggle label for Share upload", + "extractionState": "manual" }, - "No readings — replace the sensor.": { - "extractionState": "manual", - "comment": "Placeholder when the sensor has failed" + "Upload turned off and saved credentials removed.": { + "comment": "Status message after disabling Share upload", + "extractionState": "manual" }, - "Warming up — readings start soon.": { - "extractionState": "manual", - "comment": "Placeholder during warm-up" + "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" }, - "Waiting for first reading…": { - "extractionState": "manual", - "comment": "Placeholder before the first reading" + "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" }, - "Sensor started": { - "extractionState": "manual", - "comment": "Metadata label for sensor start" + "Use This Code": { + "comment": "Sensor code submit button", + "extractionState": "manual" }, - "Session ends": { - "extractionState": "manual", - "comment": "Metadata label for session end" + "Username": { + "comment": "Share account username field", + "extractionState": "manual" }, - "Sensor code": { - "extractionState": "manual", - "comment": "Metadata label for sensor code" + "Voltage A": { + "comment": "Transmitter detail label for battery A", + "extractionState": "manual" }, - "Not used": { - "extractionState": "manual", - "comment": "Value when a session runs without a sensor code" + "Voltage B": { + "comment": "Transmitter detail label for battery B", + "extractionState": "manual" }, - "Start New Sensor": { - "extractionState": "manual", - "comment": "Button to begin a new sensor 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" }, - "Enter Calibration": { - "extractionState": "manual", - "comment": "Button to enter a calibration" + "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" }, - "After warm-up": { - "extractionState": "manual", - "comment": "Why calibration is unavailable during warm-up" + "Waiting for first reading…": { + "comment": "Placeholder before the first reading", + "extractionState": "manual" }, - "No sensor": { - "extractionState": "manual", - "comment": "Why calibration is unavailable without a session" + "Waiting for you to accept pairing…": { + "comment": "Pairing status: awaiting prompt", + "extractionState": "manual" }, - "Stop Sensor Session": { - "extractionState": "manual", - "comment": "Button to stop the sensor session" + "Warm-Up": { + "comment": "Navigation title for warm-up screen", + "extractionState": "manual" }, - "Stop this sensor session?": { - "extractionState": "manual", - "comment": "Confirmation title for stopping a session" + "Warm-up is finished": { + "comment": "Warm-up screen heading once warm-up has completed", + "extractionState": "manual" }, - "Stop Session": { - "extractionState": "manual", - "comment": "Confirm stop session" + "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" }, - "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" + "Warming up": { + "comment": "Algorithm state: warm-up", + "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" + "Warming Up": { + "comment": "Session phase: warm-up", + "extractionState": "manual" }, - "Transmitter Details": { - "extractionState": "manual", - "comment": "Row to transmitter details" + "Warming up — readings start soon.": { + "comment": "Placeholder during warm-up", + "extractionState": "manual" }, - "Dexcom Share Upload": { - "extractionState": "manual", - "comment": "Row to Share upload settings" + "Wash and dry your hands with soap and water — not a gel cleaner.": { + "comment": "Calibration step 1 body", + "extractionState": "manual" }, - "How to Apply a Sensor": { - "extractionState": "manual", - "comment": "Row to the placement guide" + "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" }, - "Remove CGM from App": { - "extractionState": "manual", - "comment": "Button to delete the CGM manager" + "Yes": { + "comment": "Affirmative", + "extractionState": "manual" }, - "Remove this CGM?": { - "extractionState": "manual", - "comment": "Confirmation title for removing the CGM" + "You are almost done — the sensor session starts next.": { + "comment": "Attach step 5 note", + "extractionState": "manual" }, - "Remove CGM": { - "extractionState": "manual", - "comment": "Confirm CGM removal" + "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" }, - "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" + "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" }, - "“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" + "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" }, - "Warm-up is finished": { - "extractionState": "manual", - "comment": "Warm-up screen heading once warm-up has completed" + "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" }, - "Readings will appear as they arrive from the transmitter, about every 5 minutes.": { - "extractionState": "manual", - "comment": "Explanation shown once warm-up has completed" + "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" }, - "Expected to finish at %1$@, about %2$d minutes from now": { - "extractionState": "manual", - "comment": "Warm-up completion time (1: time, 2: minutes remaining)" + "Your sensor is warming up": { + "comment": "Warm-up screen heading", + "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)" + "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 A": { - "extractionState": "manual", - "comment": "Transmitter detail label for battery A" + "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" }, - "%d mV": { + "Scan applicator code": { "extractionState": "manual", - "comment": "Voltage in millivolts (1: millivolts)" + "comment": "Scan applicator barcode button" }, - "Voltage B": { + "Allow camera access in Settings to scan the Data Matrix code on your sensor applicator.": { "extractionState": "manual", - "comment": "Transmitter detail label for battery B" + "comment": "Camera denied alert message (sensor code)" }, - "Resistance": { + "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": "Transmitter detail label for battery resistance" + "comment": "Scanned barcode without a sensor code" }, - "Temperature": { + "The camera couldn't start. Try again or type the code manually.": { "extractionState": "manual", - "comment": "Transmitter detail label for temperature" + "comment": "Scanner failed to start" }, - "%d °C": { + "Current sensor reading": { "extractionState": "manual", - "comment": "Temperature in Celsius (1: degrees)" + "comment": "Calibration: current reading label" }, - "Last checked": { + "Large calibration change": { "extractionState": "manual", - "comment": "Transmitter detail label for last battery read" + "comment": "Calibration deviation warning title" }, - "Battery": { + "Send Anyway": { "extractionState": "manual", - "comment": "Transmitter details section header for battery" + "comment": "Confirm calibration despite large deviation" }, - "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.": { + "This is %1$@ away from the current sensor reading. A change this large can be bad for sensor accuracy; try to limit each calibration to at most %2$@ at a time.": { "extractionState": "manual", - "comment": "Footer shown when battery B is very low" + "comment": "Calibration deviation warning (1: deviation with unit, 2: recommended maximum with unit)" }, - "Voltage B is getting low. The transmitter should finish the sensor you are wearing, but order a replacement now.": { + "No recent sensor reading": { "extractionState": "manual", - "comment": "Footer shown when battery B is low" + "comment": "Calibration unavailable: title" }, - "Battery B is the cell that runs down first and determines whether the transmitter can finish a session.": { + "Calibrating compares your fingerstick to what the sensor reads right now, so it needs a recent reading. Wait for the next reading, then calibrate.": { "extractionState": "manual", - "comment": "Footer explaining what battery B means" + "comment": "Calibration unavailable: explanation" }, - "%1$@%2$@ per minute": { + "Glucose is changing": { "extractionState": "manual", - "comment": "Trend rate (1: sign, 2: formatted rate with unit)" + "comment": "Calibration unavailable while trend is not flat: title" }, - "%1$@%2$@/min": { + "Calibrating compares your fingerstick to the current reading, which only works when your glucose is steady. Wait for a flat trend arrow (→), then calibrate.": { "extractionState": "manual", - "comment": "Trend rate (1: sign, 2: formatted rate with unit)" + "comment": "Calibration unavailable while trend is not flat: explanation" } }, - "version": "1.0" + "version": "1.1" } diff --git a/G6SensorKitUI/Views/G6DetailViews.swift b/G6SensorKitUI/Views/G6DetailViews.swift index d8edcc3..fe94996 100644 --- a/G6SensorKitUI/Views/G6DetailViews.swift +++ b/G6SensorKitUI/Views/G6DetailViews.swift @@ -17,20 +17,99 @@ import G6SensorCore // MARK: - Calibration +/// Keeps the calibration screen's "current reading" live. State updates +/// alone are not enough: they only arrive WITH a reading, so without the +/// tick a disconnected transmitter would leave an old reading looking +/// fresh forever. +final class G6CalibrationViewModel: ObservableObject, G6CGMManagerObserver { + @Published private(set) var latestReading: G6StoredReading? + @Published private(set) var now = Date() + + private var tick: Timer? + + init(cgmManager: G6CGMManager) { + latestReading = cgmManager.state.latestReading + cgmManager.addStateObserver(self) + + let timer = Timer(timeInterval: 30, repeats: true) { [weak self] _ in + self?.now = Date() + } + RunLoop.main.add(timer, forMode: .common) + tick = timer + } + + deinit { + tick?.invalidate() + } + + func g6CGMManagerDidUpdateState(_ manager: G6CGMManager) { + DispatchQueue.main.async { + self.latestReading = manager.state.latestReading + } + } + + /// Readings arrive every 5 minutes; one missed reading closes the gate. + /// The extra 30 seconds absorb delivery jitter so a slightly late reading + /// does not flicker the gate. + static let readingFreshnessInterval: TimeInterval = .minutes(5.5) + + /// Why calibration is or is not currently possible. + enum Readiness { + case ready + case noFreshReading + case notFlat + } + + var freshReading: G6StoredReading? { + guard let reading = latestReading, + now.timeIntervalSince(reading.date) <= Self.readingFreshnessInterval + else { + return nil + } + return reading + } + + /// Calibrating teaches the sensor from the difference between the + /// fingerstick and the current reading; a reading that is moving cannot + /// be compared fairly, so calibration requires a flat trend. + var readiness: Readiness { + guard let reading = freshReading else { + return .noFreshReading + } + guard G6TrendArrow(dexcomRateMgDLPerMinute: reading.trendRateMgDLPerMinute) == .flat else { + return .notFlat + } + return .ready + } +} + struct G6CalibrationView: View { let cgmManager: G6CGMManager let didSubmit: () -> Void + @StateObject private var viewModel: G6CalibrationViewModel + @State private var entry: String = "" @State private var validationMessage: String? @State private var showingGuide = false + @State private var showingDeviationWarning = false @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference private static let minimumMgDL: Double = 40 private static let maximumMgDL: Double = 400 + /// Deviation from the current reading at or beyond which the big-change + /// warning shows (mg/dL). + private static let largeDeviationWarningMgDL: Double = 40 + + init(cgmManager: G6CGMManager, didSubmit: @escaping () -> Void) { + self.cgmManager = cgmManager + self.didSubmit = didSubmit + _viewModel = StateObject(wrappedValue: G6CalibrationViewModel(cgmManager: cgmManager)) + } + private var unit: HKUnit { return displayGlucosePreference.unit } @@ -47,6 +126,38 @@ struct G6CalibrationView: View { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value) } + private func formatted(mgdl value: Double) -> String { + return displayGlucosePreference.format(quantity(fromMgDL: value)) + } + + /// Latest sensor reading in mg/dL, if one exists and is fresh enough to + /// calibrate against. + private var currentValueMgDL: Double? { + return viewModel.freshReading?.glucoseMgDL + } + + private var isCalibrationBlocked: Bool { + return viewModel.readiness != .ready + } + + private var enteredMgDL: Double? { + let normalized = entry.replacingOccurrences(of: decimalSeparator, with: ".") + return Double(normalized).map { + HKQuantity(unit: unit, doubleValue: $0).doubleValue(for: .milligramsPerDeciliter) + } + } + + private var deviationMgDL: Double? { + guard let entered = enteredMgDL, let current = currentValueMgDL else { + return nil + } + return abs(entered - current) + } + + private var showsLargeDeviationWarning: Bool { + return (deviationMgDL ?? 0) >= Self.largeDeviationWarningMgDL + } + private func sanitize(_ input: String) -> String { guard usesDecimals else { return String(input.filter(\.isNumber).prefix(3)) @@ -84,13 +195,26 @@ struct G6CalibrationView: View { } } + HStack { + Text(LocalizedString("Current sensor reading", comment: "Calibration: current reading label")) + Spacer() + if let current = currentValueMgDL { + Text(formatted(mgdl: current)) + .foregroundColor(.secondary) + } else { + Text(LocalizedString("—", comment: "Placeholder for missing value")) + .foregroundColor(.secondary) + } + } + .font(.subheadline) + TextField(unit.localizedShortUnitString, text: $entry) .keyboardType(usesDecimals ? .decimalPad : .numberPad) .font(.title3.monospaced()) .padding() .background(Color(.secondarySystemBackground)) .cornerRadius(10) - .onChange(of: entry) { newValue in + .onChange(of: entry) { _, newValue in let sanitized = sanitize(newValue) if sanitized != newValue { entry = sanitized @@ -112,34 +236,75 @@ struct G6CalibrationView: View { body: LocalizedString("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") ) + switch viewModel.readiness { + case .ready: + EmptyView() + case .noFreshReading: + G6CalloutBox( + symbolName: "exclamationmark.triangle.fill", + tint: .orange, + title: LocalizedString("No recent sensor reading", comment: "Calibration unavailable: title"), + body: LocalizedString("Calibrating compares your fingerstick to what the sensor reads right now, so it needs a recent reading. Wait for the next reading, then calibrate.", comment: "Calibration unavailable: explanation") + ) + case .notFlat: + G6CalloutBox( + symbolName: "exclamationmark.triangle.fill", + tint: .orange, + title: LocalizedString("Glucose is changing", comment: "Calibration unavailable while trend is not flat: title"), + body: LocalizedString("Calibrating compares your fingerstick to the current reading, which only works when your glucose is steady. Wait for a flat trend arrow (→), then calibrate.", comment: "Calibration unavailable while trend is not flat: explanation") + ) + } + } .padding() } - Button(action: submit) { + Button(action: submitTapped) { Text(LocalizedString("Send Calibration", comment: "Calibration submit button")) .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(G6PrimaryButtonStyle()) - .disabled(entry.isEmpty) + .disabled(entry.isEmpty || isCalibrationBlocked) .padding() } .sheet(isPresented: $showingGuide) { G6CalibrationGuideView(onDone: { showingGuide = false }) } + .alert( + Text(LocalizedString("Large calibration change", comment: "Calibration deviation warning title")), + isPresented: $showingDeviationWarning + ) { + Button(role: .cancel, action: {}) { + Text(LocalizedString("Cancel", comment: "Cancel button")) + } + Button(action: submit) { + Text(LocalizedString("Send Anyway", comment: "Confirm calibration despite large deviation")) + } + } message: { + Text(String( + format: LocalizedString("This is %1$@ away from the current sensor reading. A change this large can be bad for sensor accuracy; try to limit each calibration to at most %2$@ at a time.", comment: "Calibration deviation warning (1: deviation with unit, 2: recommended maximum with unit)"), + formatted(mgdl: deviationMgDL ?? 0), + formatted(mgdl: Self.largeDeviationWarningMgDL) + )) + } } - private func submit() { - let normalized = entry.replacingOccurrences(of: decimalSeparator, with: ".") + private func submitTapped() { + // A large jump from the current reading gets a confirmation first; + // big corrections can teach the sensor the wrong thing. + guard showsLargeDeviationWarning else { + submit() + return + } + showingDeviationWarning = true + } - guard let entered = Double(normalized) else { + private func submit() { + guard let valueMgDL = enteredMgDL else { validationMessage = LocalizedString("Enter a number.", comment: "Validation message for non-numeric calibration entry") return } - let valueMgDL = HKQuantity(unit: unit, doubleValue: entered) - .doubleValue(for: .milligramsPerDeciliter) - guard (Self.minimumMgDL...Self.maximumMgDL).contains(valueMgDL) else { validationMessage = String( format: LocalizedString("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)"), 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..ddbcc27 100644 --- a/G6SensorKitUI/Views/G6SettingsView.swift +++ b/G6SensorKitUI/Views/G6SettingsView.swift @@ -151,15 +151,17 @@ final class G6SettingsViewModel: ObservableObject, G6CGMManagerObserver { } var trendSymbol: String? { - guard let rate = state.latestReading?.trendRateMgDLPerMinute, !isSignalLost else { + guard !isSignalLost, + let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: state.latestReading?.trendRateMgDLPerMinute) + else { return nil } - switch rate { - case ..<(-3): return "arrow.down" - case ..<(-1): return "arrow.down.right" - case ..<1: return "arrow.right" - case ..<3: return "arrow.up.right" - default: return "arrow.up" + switch arrow { + case .downDownDown, .downDown: return "arrow.down" + case .down: return "arrow.down.right" + case .flat: return "arrow.right" + case .up, .upUp: return "arrow.up.right" + case .upUpUp: return "arrow.up" } } 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/Auth/G6TransmitterModel.swift b/Sources/G6SensorCore/Auth/G6TransmitterModel.swift new file mode 100644 index 0000000..0558c08 --- /dev/null +++ b/Sources/G6SensorCore/Auth/G6TransmitterModel.swift @@ -0,0 +1,27 @@ +// +// G6TransmitterModel.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// The G6-family model, classified from the transmitter ID the user entered. +public enum G6TransmitterModel: CaseIterable { + case g6 + case one + + /// Dexcom ONE transmitters use `5…` or `C…`; G6 uses `8…`. An unrecognized + /// prefix falls back to G6, the stock case. + public init(transmitterID: String) { + switch transmitterID.first { + case "5", + "c", + "C": + self = .one + default: + self = .g6 + } + } +} diff --git a/Sources/G6SensorCore/Auth/TransmitterID.swift b/Sources/G6SensorCore/Auth/TransmitterID.swift index f81a1eb..cebeffe 100644 --- a/Sources/G6SensorCore/Auth/TransmitterID.swift +++ b/Sources/G6SensorCore/Auth/TransmitterID.swift @@ -21,6 +21,11 @@ public struct TransmitterID { self.id = id } + /// The G6-family model, classified from the transmitter ID prefix. + public var model: G6TransmitterModel { + return G6TransmitterModel(transmitterID: id) + } + private var cryptKey: Data? { return "00\(id)00\(id)".data(using: .utf8) } diff --git a/Sources/G6SensorCore/Model/G6TrendArrow.swift b/Sources/G6SensorCore/Model/G6TrendArrow.swift new file mode 100644 index 0000000..95ad0e7 --- /dev/null +++ b/Sources/G6SensorCore/Model/G6TrendArrow.swift @@ -0,0 +1,50 @@ +// +// G6TrendArrow.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// The Dexcom trend arrow for a reported rate of change. +/// +/// The raw values match LoopKit's `GlucoseTrend` so the integration layer +/// can bridge with `GlucoseTrend(rawValue:)`. +public enum G6TrendArrow: Int, CaseIterable { + case upUpUp = 1 + case upUp = 2 + case up = 3 + case flat = 4 + case down = 5 + case downDown = 6 + case downDownDown = 7 + + /// Beyond this the official apps show no arrow at all. Reachable: the + /// rate arrives as Int8 tenths (0x7F is the "no trend" sentinel), so + /// roughly -12.8 to +12.6 comes in over the wire. + private static let dexcomArrowLimit = 8.0 + + /// The Dexcom arrow for the rate the transmitter reported, in + /// (mg/dL)/min. + /// + /// Thresholds match the apps' shared trend-arrow table: every threshold + /// belongs to the steeper arrow, `flat` excludes both of its ends, and + /// |rate| above 8 is no arrow. The transmitter sends the rate; the + /// receiver only buckets it. + public init?(dexcomRateMgDLPerMinute rate: Double?) { + guard let rate = rate, + (-Self.dexcomArrowLimit ... Self.dexcomArrowLimit).contains(rate) + else { return nil } + + switch rate { + case ...(-3): self = .downDownDown + case ...(-2): self = .downDown + case ...(-1): self = .down + case ..<1: self = .flat + case ..<2: self = .up + case ..<3: self = .upUp + default: self = .upUpUp + } + } +} diff --git a/Sources/G6SensorCore/Model/TransmitterManagerState.swift b/Sources/G6SensorCore/Model/TransmitterManagerState.swift index 6511a7e..cd57195 100644 --- a/Sources/G6SensorCore/Model/TransmitterManagerState.swift +++ b/Sources/G6SensorCore/Model/TransmitterManagerState.swift @@ -69,7 +69,7 @@ public struct TransmitterManagerState: RawRepresentable, Equatable { let sensorStartOffset = rawValue["sensorStartOffset"] as? UInt32 let transmitterExpiryInDays = (rawValue["transmitterExpiryInDays"] as? UInt16) - ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16($0) } + ?? (rawValue["transmitterExpiryInDays"] as? Int).map { UInt16(clamping: $0) } let sensorLifeDays = rawValue["sensorLifeDays"] as? Int ?? Self.defaultSensorLifeDays 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..dbf6c18 --- /dev/null +++ b/Sources/G6SensorCore/Session/CommandQueue.swift @@ -0,0 +1,74 @@ +// +// CommandQueue.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import Foundation + +/// 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) + + public private(set) var commands: [Command] + + public init(_ commands: [Command] = []) { + self.commands = commands + } + + public init(rawValues: [Command.RawValue]) { + self.init(rawValues.compactMap(Command.init(rawValue:))) + } + + public var rawValues: [Command.RawValue] { + commands.map(\.rawValue) + } + + public var isEmpty: Bool { + commands.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. + /// + /// - Returns: the number of queued commands the new one replaced. + @discardableResult public mutating func enqueue(_ command: Command) -> Int { + let before = commands.count + commands.removeAll { $0.supersededBy(command) } + let superseded = before - commands.count + commands.append(command) + 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]) { + var dropped: [Command] = [] + var next: Command? + while let candidate = commands.first { + commands.removeFirst() + if case let .calibrateSensor(_, date) = candidate, + now.timeIntervalSince(date) > Self.staleCalibrationInterval + { + dropped.append(candidate) + continue + } + next = candidate + break + } + return (next, dropped) + } + + public mutating func removeAll() { + commands.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/Sources/G6SensorCore/Transport/PeripheralManager.swift b/Sources/G6SensorCore/Transport/PeripheralManager.swift index 8239e10..834298b 100644 --- a/Sources/G6SensorCore/Transport/PeripheralManager.swift +++ b/Sources/G6SensorCore/Transport/PeripheralManager.swift @@ -147,7 +147,7 @@ extension PeripheralManager { } } - private func applyConfiguration(discoveryTimeout: TimeInterval = 2) throws { + private func applyConfiguration(discoveryTimeout: TimeInterval = 10) throws { try discoverServices(configuration.serviceCharacteristics.keys.map { $0 }, timeout: discoveryTimeout) for service in peripheral.services ?? [] { @@ -400,11 +400,13 @@ extension PeripheralManager: CBPeripheralDelegate { var notifyDelegate = false if let index = commandConditions.firstIndex(where: { (condition) -> Bool in - if case .valueUpdate(characteristic: characteristic, matching: let matching) = condition { - return matching?(characteristic.value) ?? true - } else { + guard case .valueUpdate(characteristic: let conditionCharacteristic, matching: let matching) = condition, + conditionCharacteristic == characteristic + else { return false } + + return matching?(characteristic.value) ?? true }) { commandConditions.remove(at: index) commandError = error @@ -414,7 +416,9 @@ extension PeripheralManager: CBPeripheralDelegate { } } else if let macro = configuration.valueUpdateMacros[characteristic.uuid] { macro(self) - } else if commandConditions.isEmpty { + } else { + // Unclaimed by any condition or macro: pass it along to the + // delegate even while an unrelated command is still pending. notifyDelegate = true // execute after the unlock } diff --git a/Tests/G6SensorCoreTests/CommandQueueTests.swift b/Tests/G6SensorCoreTests/CommandQueueTests.swift new file mode 100644 index 0000000..bdcb7cd --- /dev/null +++ b/Tests/G6SensorCoreTests/CommandQueueTests.swift @@ -0,0 +1,95 @@ +// +// 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) + } +} diff --git a/Tests/G6SensorCoreTests/G6TrendArrowTests.swift b/Tests/G6SensorCoreTests/G6TrendArrowTests.swift new file mode 100644 index 0000000..9062794 --- /dev/null +++ b/Tests/G6SensorCoreTests/G6TrendArrowTests.swift @@ -0,0 +1,53 @@ +// +// G6TrendArrowTests.swift +// G6SensorKit +// +// Copyright © 2026 Nightscout Foundation. MIT License. +// + +import XCTest +@testable import G6SensorCore + +/// Rates are integer tenths on the wire, so every case here is reachable. +class G6TrendArrowTests: XCTestCase { + func testNoRateHasNoArrow() { + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: nil)) + } + + func testArrowsByRate() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -4), .downDownDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -2.5), .downDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -1.5), .down) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 0), .flat) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 1.5), .up) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 2.5), .upUp) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 4), .upUpUp) + } + + /// Every threshold belongs to the steeper arrow, on both sides. + /// The old `..<(-3)` bucketing gave a single-down arrow at exactly -3.0. + func testThresholdsTakeTheSteeperArrow() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -3), .downDownDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 3), .upUpUp) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -2), .downDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 2), .upUp) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -1), .down) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 1), .up) + } + + /// Flat excludes both of its ends, so it is the narrowest bucket. + func testFlatIsOnlyBetweenTheUnitThresholds() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -0.9), .flat) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 0.9), .flat) + } + + /// Past +/-8 the official apps show no arrow rather than a triple one. + /// The wire carries Int8 tenths, so this reaches roughly +/-12.6. + func testImplausibleRatesHaveNoArrow() { + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: -8), .downDownDown) + XCTAssertEqual(G6TrendArrow(dexcomRateMgDLPerMinute: 8), .upUpUp) + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: -8.1)) + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: 8.1)) + XCTAssertNil(G6TrendArrow(dexcomRateMgDLPerMinute: 12.6)) + } +} 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")) + } +} diff --git a/Tests/G6SensorCoreTests/TransmitterIDTests.swift b/Tests/G6SensorCoreTests/TransmitterIDTests.swift index 59aedba..c807053 100644 --- a/Tests/G6SensorCoreTests/TransmitterIDTests.swift +++ b/Tests/G6SensorCoreTests/TransmitterIDTests.swift @@ -16,5 +16,18 @@ class TransmitterIDTests: XCTestCase { XCTAssertEqual("e60d4a7999b0fbb2", id.computeHash(of: Data(hexadecimalString: "0123456789abcdef")!)!.hexadecimalString) } - + + /// The prefix decides the model: 8… is G6, 5… and C… are Dexcom ONE. + func testModelByPrefix() { + XCTAssertEqual(TransmitterID(id: "8ZXY12").model, .g6) + XCTAssertEqual(TransmitterID(id: "5ZXY12").model, .one) + XCTAssertEqual(TransmitterID(id: "CZXY12").model, .one) + XCTAssertEqual(TransmitterID(id: "cZXY12").model, .one) + } + + /// An unrecognized prefix is the stock case, not a failure. + func testUnknownPrefixFallsBackToG6() { + XCTAssertEqual(TransmitterID(id: "9ZXY12").model, .g6) + } + }