diff --git a/nightguard WatchKit Extension/ChartPainter.swift b/nightguard WatchKit Extension/ChartPainter.swift index 380fc6cd..20f2e26e 100644 --- a/nightguard WatchKit Extension/ChartPainter.swift +++ b/nightguard WatchKit Extension/ChartPainter.swift @@ -301,7 +301,8 @@ class ChartPainter { } fileprivate func durationIsMoreThan6Hours(_ minTimestamp : Double, maxTimestamp : Double) -> Bool { - return maxTimestamp - minTimestamp > 6 * 60 * 60 * 1000 + let sixHours = Double(6 * 60 * 60 * 1000) + return (maxTimestamp - minTimestamp) > sixHours } fileprivate func paintEverySecondHour(_ context : CGContext, attrs : [NSAttributedStringKey : Any]) { diff --git a/nightguard WatchKit Extension/UserDefaultsRepository.swift b/nightguard WatchKit Extension/UserDefaultsRepository.swift index 3f7fcd6d..aaf9b2dd 100644 --- a/nightguard WatchKit Extension/UserDefaultsRepository.swift +++ b/nightguard WatchKit Extension/UserDefaultsRepository.swift @@ -100,6 +100,9 @@ class UserDefaultsRepository { static let volumeKeysOnAlertSnoozeOption = UserDefaultsValue(key: "volumeKeysOnAlertSnoozeOption", default: .doNothing) #endif + // show/hide stats + static let showStats = UserDefaultsValue(key: "showStats", default: true) + /* Parses the URI entered in the UI and extracts the token if one is present. */ fileprivate static func parseBaseUri() { url = nil diff --git a/nightguard.xcodeproj/project.pbxproj b/nightguard.xcodeproj/project.pbxproj index 1e3892cd..4a97e426 100644 --- a/nightguard.xcodeproj/project.pbxproj +++ b/nightguard.xcodeproj/project.pbxproj @@ -130,6 +130,10 @@ D133079B21C85FAE00DC6879 /* Matrix.swift in Sources */ = {isa = PBXBuildFile; fileRef = D133079121C85FAE00DC6879 /* Matrix.swift */; }; D133079E21C8D4CF00DC6879 /* PredictionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D133079D21C8D4CF00DC6879 /* PredictionService.swift */; }; D133079F21C8D4CF00DC6879 /* PredictionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D133079D21C8D4CF00DC6879 /* PredictionService.swift */; }; + D1358AE42234F80A00D0FA87 /* SMDiagramView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1358AE32234F80A00D0FA87 /* SMDiagramView.swift */; }; + D1358AE62234FB2600D0FA87 /* BasicStatsPanelView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D1358AE52234FB2600D0FA87 /* BasicStatsPanelView.xib */; }; + D1358AE82234FB5000D0FA87 /* BasicStatsPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1358AE72234FB5000D0FA87 /* BasicStatsPanelView.swift */; }; + D1358AEC2237C44600D0FA87 /* XibLoadedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1358AEB2237C44600D0FA87 /* XibLoadedView.swift */; }; D13A4C1C2221E43A00C71F08 /* GenericWatchMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D13A4C1B2221E43A00C71F08 /* GenericWatchMessage.swift */; }; D13A4C1D2221E43A00C71F08 /* GenericWatchMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D13A4C1B2221E43A00C71F08 /* GenericWatchMessage.swift */; }; D13A4C1E2221E43A00C71F08 /* GenericWatchMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D13A4C1B2221E43A00C71F08 /* GenericWatchMessage.swift */; }; @@ -149,9 +153,17 @@ D16C8EDD22106C9300192117 /* QuickSnoozeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16C8EDC22106C9300192117 /* QuickSnoozeOption.swift */; }; D16C8EDF2211047300192117 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16C8EDE2211047300192117 /* UIViewController+Extensions.swift */; }; D16C8EE22213022E00192117 /* UserInteractionDetectorWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16C8EE12213022E00192117 /* UserInteractionDetectorWindow.swift */; }; + D17F7938224D3C500074907B /* PersistentHighViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17F7937224D3C500074907B /* PersistentHighViewController.swift */; }; + D18C5D8A22293C220099D96E /* BasicStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = D18C5D8922293C220099D96E /* BasicStats.swift */; }; + D18C5D8B22293C230099D96E /* BasicStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = D18C5D8922293C220099D96E /* BasicStats.swift */; }; + D18C5D8C22293C230099D96E /* BasicStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = D18C5D8922293C220099D96E /* BasicStats.swift */; }; D1AEFED821FE00A200821DF6 /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AEFED721FE00A200821DF6 /* AnyConvertible.swift */; }; D1AEFED921FE011B00821DF6 /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AEFED721FE00A200821DF6 /* AnyConvertible.swift */; }; D1AEFEDA21FE011C00821DF6 /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AEFED721FE00A200821DF6 /* AnyConvertible.swift */; }; + D1B777E42243E27B003FEDF0 /* TouchReportingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B777E32243E27B003FEDF0 /* TouchReportingView.swift */; }; + D1B777E62244C11B003FEDF0 /* Comparable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B777E52244C11B003FEDF0 /* Comparable+Extensions.swift */; }; + D1B777E72244C43D003FEDF0 /* Comparable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B777E52244C11B003FEDF0 /* Comparable+Extensions.swift */; }; + D1B777E82244C43E003FEDF0 /* Comparable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B777E52244C11B003FEDF0 /* Comparable+Extensions.swift */; }; D1BA903520D3033500A0EBD1 /* GroupedLabelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BA903420D3033500A0EBD1 /* GroupedLabelsView.swift */; }; D1BA903820D3046600A0EBD1 /* PaddingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BA903720D3046600A0EBD1 /* PaddingLabel.swift */; }; D1BA903A20D304BE00A0EBD1 /* UIView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BA903920D304BE00A0EBD1 /* UIView+Extensions.swift */; }; @@ -198,6 +210,11 @@ D1F2F57C21EF133600CEB874 /* Matrix+Append.swift in Sources */ = {isa = PBXBuildFile; fileRef = D133078E21C85FAC00DC6879 /* Matrix+Append.swift */; }; D1F2F57D21EF133A00CEB874 /* Matrix+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = D133078F21C85FAD00DC6879 /* Matrix+Description.swift */; }; D1F2F57E21EF134400CEB874 /* RegressionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D133078D21C85FAC00DC6879 /* RegressionHelper.swift */; }; + D1F94ECC223FFDF200BB0A4D /* BasicStatsControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F94ECB223FFDF100BB0A4D /* BasicStatsControl.swift */; }; + D1F94ECE223FFE2A00BB0A4D /* GlucoseDistributionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F94ECD223FFE2A00BB0A4D /* GlucoseDistributionView.swift */; }; + D1F94ED0223FFEFF00BB0A4D /* A1cView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F94ECF223FFEFF00BB0A4D /* A1cView.swift */; }; + D1F94ED2223FFF3900BB0A4D /* ReadingsStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F94ED1223FFF3900BB0A4D /* ReadingsStatsView.swift */; }; + D1F94ED4223FFF6C00BB0A4D /* StatsPeriodSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F94ED3223FFF6C00BB0A4D /* StatsPeriodSelectorView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -346,6 +363,10 @@ D133079021C85FAD00DC6879 /* Regression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Regression.swift; sourceTree = ""; }; D133079121C85FAE00DC6879 /* Matrix.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Matrix.swift; sourceTree = ""; }; D133079D21C8D4CF00DC6879 /* PredictionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionService.swift; sourceTree = ""; }; + D1358AE32234F80A00D0FA87 /* SMDiagramView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SMDiagramView.swift; sourceTree = ""; }; + D1358AE52234FB2600D0FA87 /* BasicStatsPanelView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicStatsPanelView.xib; sourceTree = ""; }; + D1358AE72234FB5000D0FA87 /* BasicStatsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicStatsPanelView.swift; sourceTree = ""; }; + D1358AEB2237C44600D0FA87 /* XibLoadedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XibLoadedView.swift; sourceTree = ""; }; D13A4C1B2221E43A00C71F08 /* GenericWatchMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericWatchMessage.swift; sourceTree = ""; }; D13A4C1F2221E49200C71F08 /* NightSafeMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightSafeMessage.swift; sourceTree = ""; }; D15055AB2200CD6500F31C1F /* UserDefaultsValueGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValueGroups.swift; sourceTree = ""; }; @@ -360,7 +381,11 @@ D16C8EDC22106C9300192117 /* QuickSnoozeOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSnoozeOption.swift; sourceTree = ""; }; D16C8EDE2211047300192117 /* UIViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extensions.swift"; sourceTree = ""; }; D16C8EE12213022E00192117 /* UserInteractionDetectorWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInteractionDetectorWindow.swift; sourceTree = ""; }; + D17F7937224D3C500074907B /* PersistentHighViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHighViewController.swift; sourceTree = ""; }; + D18C5D8922293C220099D96E /* BasicStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicStats.swift; sourceTree = ""; }; D1AEFED721FE00A200821DF6 /* AnyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyConvertible.swift; sourceTree = ""; }; + D1B777E32243E27B003FEDF0 /* TouchReportingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchReportingView.swift; sourceTree = ""; }; + D1B777E52244C11B003FEDF0 /* Comparable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Comparable+Extensions.swift"; sourceTree = ""; }; D1BA903420D3033500A0EBD1 /* GroupedLabelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedLabelsView.swift; sourceTree = ""; }; D1BA903720D3046600A0EBD1 /* PaddingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingLabel.swift; sourceTree = ""; }; D1BA903920D304BE00A0EBD1 /* UIView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extensions.swift"; sourceTree = ""; }; @@ -388,6 +413,11 @@ D1EBB6A82223FF0200CE27FF /* MissedReadingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedReadingsViewController.swift; sourceTree = ""; }; D1ECDA1E2201075B002BE6F9 /* ObservationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationToken.swift; sourceTree = ""; }; D1ECDA252202C8F0002BE6F9 /* UIColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColorExtension.swift; sourceTree = ""; }; + D1F94ECB223FFDF100BB0A4D /* BasicStatsControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicStatsControl.swift; sourceTree = ""; }; + D1F94ECD223FFE2A00BB0A4D /* GlucoseDistributionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDistributionView.swift; sourceTree = ""; }; + D1F94ECF223FFEFF00BB0A4D /* A1cView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = A1cView.swift; sourceTree = ""; }; + D1F94ED1223FFF3900BB0A4D /* ReadingsStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingsStatsView.swift; sourceTree = ""; }; + D1F94ED3223FFF6C00BB0A4D /* StatsPeriodSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsPeriodSelectorView.swift; sourceTree = ""; }; DFAB5E556FA57C770FDF2924 /* Pods_nightguardTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_nightguardTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FDA8B19A6EB3652EEDD03592 /* Pods_nightguardUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_nightguardUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FE4022D9F5273F6FF43EA39D /* Pods_nightguard_WatchKit_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_nightguard_WatchKit_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -615,6 +645,7 @@ D133078721C85D4300DC6879 /* BloodSugarArrayExtension.swift */, 432E62DE1D10ACD200DD7978 /* Units.swift */, D16C8EDC22106C9300192117 /* QuickSnoozeOption.swift */, + D18C5D8922293C220099D96E /* BasicStats.swift */, ); name = domain; sourceTree = ""; @@ -664,6 +695,7 @@ D1D1623B2218BF7A006F990A /* UIScreen+AnimatedBrightness.swift */, D16C8EDE2211047300192117 /* UIViewController+Extensions.swift */, D16C8EDA221068F000192117 /* VolumeChangeDetector.swift */, + D1B777E52244C11B003FEDF0 /* Comparable+Extensions.swift */, ); name = helpers; sourceTree = ""; @@ -721,6 +753,7 @@ D1EBB6A82223FF0200CE27FF /* MissedReadingsViewController.swift */, D160D091220E4248002B4633 /* AlertVolumeViewController.swift */, D16C8ED62210377000192117 /* SnoozeActionsViewController.swift */, + D17F7937224D3C500074907B /* PersistentHighViewController.swift */, ); name = forms; sourceTree = ""; @@ -728,9 +761,12 @@ D1BA903620D303E700A0EBD1 /* views */ = { isa = PBXGroup; children = ( + D1F94ECA223FFD5400BB0A4D /* stats */, D1BA903420D3033500A0EBD1 /* GroupedLabelsView.swift */, D1BA903720D3046600A0EBD1 /* PaddingLabel.swift */, D1BA903920D304BE00A0EBD1 /* UIView+Extensions.swift */, + D1358AEB2237C44600D0FA87 /* XibLoadedView.swift */, + D1B777E32243E27B003FEDF0 /* TouchReportingView.swift */, ); name = views; sourceTree = ""; @@ -745,6 +781,21 @@ name = watch; sourceTree = ""; }; + D1F94ECA223FFD5400BB0A4D /* stats */ = { + isa = PBXGroup; + children = ( + D1358AE32234F80A00D0FA87 /* SMDiagramView.swift */, + D1F94ECB223FFDF100BB0A4D /* BasicStatsControl.swift */, + D1F94ECF223FFEFF00BB0A4D /* A1cView.swift */, + D1F94ECD223FFE2A00BB0A4D /* GlucoseDistributionView.swift */, + D1F94ED1223FFF3900BB0A4D /* ReadingsStatsView.swift */, + D1F94ED3223FFF6C00BB0A4D /* StatsPeriodSelectorView.swift */, + D1358AE52234FB2600D0FA87 /* BasicStatsPanelView.xib */, + D1358AE72234FB5000D0FA87 /* BasicStatsPanelView.swift */, + ); + name = stats; + sourceTree = ""; + }; D1FAA4AB20D7AE6B0062B333 /* controllers */ = { isa = PBXGroup; children = ( @@ -940,6 +991,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D1358AE62234FB2600D0FA87 /* BasicStatsPanelView.xib in Resources */, D1DA7C7521C0E1ED00B39675 /* Stats.storyboard in Resources */, 43647BD81BFF6435004389F9 /* LaunchScreen.storyboard in Resources */, 43F1E1061D07698000C329A2 /* Media.xcassets in Resources */, @@ -1140,6 +1192,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D1358AE82234FB5000D0FA87 /* BasicStatsPanelView.swift in Sources */, D1D96CC321FB0FA80035A60E /* SnoozeMessage.swift in Sources */, D1D1623C2218BF7A006F990A /* UIScreen+AnimatedBrightness.swift in Sources */, D1BA903820D3046600A0EBD1 /* PaddingLabel.swift in Sources */, @@ -1151,6 +1204,7 @@ D16C8EDD22106C9300192117 /* QuickSnoozeOption.swift in Sources */, 437EEDC11FB70FC200694EAD /* NightscoutCacheService.swift in Sources */, D160D08A220C608D002B4633 /* FastRiseDropViewController.swift in Sources */, + D1F94ED0223FFEFF00BB0A4D /* A1cView.swift in Sources */, 43119B891C38404200DD6D35 /* NightscoutDataRepository.swift in Sources */, D133079621C85FAE00DC6879 /* Matrix+Description.swift in Sources */, D1CE8CC42204B19C0075FF8A /* CustomFormViewController.swift in Sources */, @@ -1166,6 +1220,7 @@ D16C8EDF2211047300192117 /* UIViewController+Extensions.swift in Sources */, D1ECDA262202C8F0002BE6F9 /* UIColorExtension.swift in Sources */, D15055AC2200CD6500F31C1F /* UserDefaultsValueGroups.swift in Sources */, + D1F94ECE223FFE2A00BB0A4D /* GlucoseDistributionView.swift in Sources */, D160D086220C534E002B4633 /* ButtonRowWithDynamicDetails.swift in Sources */, D133079A21C85FAE00DC6879 /* Matrix.swift in Sources */, 4351E6B01D2EFA8C001E4AE4 /* StatisticsRepository.swift in Sources */, @@ -1176,14 +1231,18 @@ D16C8ED92210687200192117 /* MPVolumeViewExtension.swift in Sources */, 43E89E571E659102005B0A65 /* ChartScene.swift in Sources */, 43288A6A1D1C68D400EE3999 /* StatsViewController.swift in Sources */, + D1B777E62244C11B003FEDF0 /* Comparable+Extensions.swift in Sources */, 43119B831C382BBB00DD6D35 /* UserDefaultsRepository.swift in Sources */, 43F1E11B1D076D9700C329A2 /* TimeService.swift in Sources */, D1EBB6A92223FF0200CE27FF /* MissedReadingsViewController.swift in Sources */, + D1F94ECC223FFDF200BB0A4D /* BasicStatsControl.swift in Sources */, + D1358AEC2237C44600D0FA87 /* XibLoadedView.swift in Sources */, 432E62EB1D15EFC600DD7978 /* FloatExtension.swift in Sources */, 43FCE625208B80840080DA0A /* SnoozeAlarmViewController.swift in Sources */, D133079E21C8D4CF00DC6879 /* PredictionService.swift in Sources */, 430FD8D41E7C8738002A23F0 /* UIViewControllerExtension.swift in Sources */, 43F1E1051D07698000C329A2 /* MainViewController.swift in Sources */, + D1F94ED2223FFF3900BB0A4D /* ReadingsStatsView.swift in Sources */, D1BA903520D3033500A0EBD1 /* GroupedLabelsView.swift in Sources */, D13A4C1C2221E43A00C71F08 /* GenericWatchMessage.swift in Sources */, D13A4C202221E49200C71F08 /* NightSafeMessage.swift in Sources */, @@ -1193,6 +1252,7 @@ D123EFEE21FB782C00CE8718 /* NightscoutDataMessage.swift in Sources */, D15055B02200D31300F31C1F /* DeviceSize.swift in Sources */, D133079421C85FAE00DC6879 /* Matrix+Append.swift in Sources */, + D1358AE42234F80A00D0FA87 /* SMDiagramView.swift in Sources */, 43119B941C3AAE6600DD6D35 /* UIColorChanger.swift in Sources */, D1D96CC021FB0F2E0035A60E /* UserDefaultsSyncMessage.swift in Sources */, D1AEFED821FE00A200821DF6 /* AnyConvertible.swift in Sources */, @@ -1205,6 +1265,7 @@ D1BA903A20D304BE00A0EBD1 /* UIView+Extensions.swift in Sources */, D123EFEB21FB70A700CE8718 /* KeepAwakeMessage.swift in Sources */, D16C8ED72210377000192117 /* SnoozeActionsViewController.swift in Sources */, + D1F94ED4223FFF6C00BB0A4D /* StatsPeriodSelectorView.swift in Sources */, 4351E6AE1D2ADFBE001E4AE4 /* StatsPrefsViewController.swift in Sources */, D1D96CB621FAF6890035A60E /* WatchMessage.swift in Sources */, 43119B811C382A0E00DD6D35 /* AppConstants.swift in Sources */, @@ -1212,7 +1273,10 @@ D133078421C424A800DC6879 /* ArrayExtension.swift in Sources */, D1DA7C6C21BE810900B39675 /* UIApplicationExtension.swift in Sources */, D1D96CB121F85BC70035A60E /* UserDefaultsValue.swift in Sources */, + D1B777E42243E27B003FEDF0 /* TouchReportingView.swift in Sources */, + D18C5D8A22293C220099D96E /* BasicStats.swift in Sources */, 43F1E0EE1D07693300C329A2 /* AlarmRule.swift in Sources */, + D17F7938224D3C500074907B /* PersistentHighViewController.swift in Sources */, 432E62EF1D17359600DD7978 /* StringExtension.swift in Sources */, D1D1623E221AAC05006F990A /* WatchSyncRequestMessage.swift in Sources */, 43F1E1011D07698000C329A2 /* AppDelegate.swift in Sources */, @@ -1237,10 +1301,12 @@ D1ECDA202201075B002BE6F9 /* ObservationToken.swift in Sources */, 432E62E71D11F61500DD7978 /* UserDefaultsRepositoryTest.swift in Sources */, 43794F491C30489C00DB8B58 /* NightscoutDataRepository.swift in Sources */, + D1B777E72244C43D003FEDF0 /* Comparable+Extensions.swift in Sources */, 432E62E01D10AF3800DD7978 /* Units.swift in Sources */, D1F2F57B21EF133000CEB874 /* Matrix.swift in Sources */, 432E62D91D0CC3AC00DD7978 /* BloodSugar.swift in Sources */, 437EEDC31FB716C400694EAD /* StatisticsRepository.swift in Sources */, + D18C5D8B22293C230099D96E /* BasicStats.swift in Sources */, D133078C21C85DAA00DC6879 /* ArrayExtension.swift in Sources */, 432E62DA1D0CC3DE00DD7978 /* TimeService.swift in Sources */, D1F2F57E21EF134400CEB874 /* RegressionHelper.swift in Sources */, @@ -1321,6 +1387,7 @@ 43647C0B1BFF6435004389F9 /* ExtensionDelegate.swift in Sources */, 43647C091BFF6435004389F9 /* InterfaceController.swift in Sources */, 43119B881C383CF500DD6D35 /* UIColorChanger.swift in Sources */, + D18C5D8C22293C230099D96E /* BasicStats.swift in Sources */, 43288A681D1B359D00EE3999 /* StringExtension.swift in Sources */, 438066FE1D21C2350021B618 /* AppMessageService.swift in Sources */, 437EEDC51FB7185500694EAD /* UserDefaultsRepository.swift in Sources */, @@ -1328,6 +1395,7 @@ D1D96CC121FB0F2E0035A60E /* UserDefaultsSyncMessage.swift in Sources */, D133078A21C85D5400DC6879 /* BloodSugarArrayExtension.swift in Sources */, D13A4C1E2221E43A00C71F08 /* GenericWatchMessage.swift in Sources */, + D1B777E82244C43E003FEDF0 /* Comparable+Extensions.swift in Sources */, D123EFEF21FB782C00CE8718 /* NightscoutDataMessage.swift in Sources */, 43794F421C2F435A00DB8B58 /* AppConstants.swift in Sources */, 439C39181C0E002F00D89872 /* ChartPainter.swift in Sources */, @@ -1500,7 +1568,7 @@ "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; INFOPLIST_FILE = "nightguard WatchKit Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguard.watchkitapp.watchkitextension"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguard.watchkitapp.watchkitextension; PRODUCT_NAME = nightguard; PROVISIONING_PROFILE = ""; SDKROOT = watchos; @@ -1522,7 +1590,7 @@ "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; INFOPLIST_FILE = "nightguard WatchKit Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguard.watchkitapp.watchkitextension"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguard.watchkitapp.watchkitextension; PRODUCT_NAME = nightguard; PROVISIONING_PROFILE = ""; SDKROOT = watchos; @@ -1546,7 +1614,7 @@ IBSC_MODULE = scoutwatch_WatchKit_Extension; INFOPLIST_FILE = "nightguard WatchKit App/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguard.watchkitapp"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguard.watchkitapp; PRODUCT_NAME = nightguard; SDKROOT = watchos; SKIP_INSTALL = YES; @@ -1569,7 +1637,7 @@ IBSC_MODULE = scoutwatch_WatchKit_Extension; INFOPLIST_FILE = "nightguard WatchKit App/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguard.watchkitapp"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguard.watchkitapp; PRODUCT_NAME = nightguard; SDKROOT = watchos; SKIP_INSTALL = YES; @@ -1591,7 +1659,7 @@ ENABLE_BITCODE = YES; INFOPLIST_FILE = nightguard/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguard"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguard; PRODUCT_NAME = nightguard; PROVISIONING_PROFILE = ""; SWIFT_SWIFT3_OBJC_INFERENCE = Default; @@ -1612,7 +1680,7 @@ ENABLE_BITCODE = YES; INFOPLIST_FILE = nightguard/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguard"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguard; PRODUCT_NAME = nightguard; PROVISIONING_PROFILE = ""; SWIFT_SWIFT3_OBJC_INFERENCE = Default; @@ -1628,7 +1696,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; INFOPLIST_FILE = nightguardTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguardTests"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguardTests; PRODUCT_NAME = nightguard; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; @@ -1643,7 +1711,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; INFOPLIST_FILE = nightguardTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguardTests"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguardTests; PRODUCT_NAME = nightguard; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; @@ -1657,7 +1725,7 @@ buildSettings = { INFOPLIST_FILE = nightguardUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguardUITests"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguardUITests; PRODUCT_NAME = nightguard; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; @@ -1672,7 +1740,7 @@ buildSettings = { INFOPLIST_FILE = nightguardUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "de.my-wan.dhe.nightguardUITests"; + PRODUCT_BUNDLE_IDENTIFIER = de.my-wan.dhe.nightguardUITests; PRODUCT_NAME = nightguard; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; diff --git a/nightguard/A1cView.swift b/nightguard/A1cView.swift new file mode 100644 index 00000000..234f5317 --- /dev/null +++ b/nightguard/A1cView.swift @@ -0,0 +1,101 @@ +// +// A1cView.swift +// nightguard +// +// Created by Florian Preknya on 3/18/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +extension UIColor { + + // Creates a color-meter like UIColor by placing the given value in correspondence with its good-bad range, resulting a green color if the value is very good on that scale, a red color if is very bad or yellow & compositions with red-green if is in between + static func redYellowGreen(for value: CGFloat, bestValue: CGFloat, worstValue: CGFloat) -> UIColor { + if bestValue < worstValue { + let power = max(min((worstValue - CGFloat(value)) / (worstValue - bestValue), 1), 0) + return UIColor(red: 1 - power, green: power, blue: 0, alpha: 1) + } else { + let power = max(min((bestValue - CGFloat(value)) / (bestValue - worstValue), 1), 0) + return UIColor(red: power, green: 1 - power, blue: 0, alpha: 1) + } + } +} + +/** + A stats view that displays the A1c value, the average BG value, standard deviation & variation values. It appreciates the values by giving a colored feedback to user: green - good values, yellow - okish, red - pretty bad. + */ +class A1cView: BasicStatsControl { + + override func createPages() -> [StatsPage] { + return [ + StatsPage(name: "A1c", formattedValue: model?.formattedA1c), + StatsPage(name: "Average", formattedValue: model?.formattedAverageGlucose?.replacingOccurrences(of: " ", with: "\n")), + StatsPage(name: "Std Deviation", formattedValue: model?.formattedStandardDeviation?.replacingOccurrences(of: " ", with: "\n")), + StatsPage(name: "Coefficient of Variation", formattedValue: model?.formattedCoefficientOfVariation) + ] + } + + fileprivate var a1cColor: UIColor? { + + guard let a1c = model?.a1c else { + return nil + } + + return UIColor.redYellowGreen(for: CGFloat(a1c), bestValue: 5.5, worstValue: 8.5) + } + + fileprivate var variationColor: UIColor? { + + guard let coefficientOfVariation = model?.coefficientOfVariation else { + return nil + } + + return UIColor.redYellowGreen(for: CGFloat(coefficientOfVariation), bestValue: 0.3, worstValue: 0.5) + } + + fileprivate var modelColor: UIColor? { + return (currentPageIndex < 2) ? a1cColor : variationColor + } + + override func commonInit() { + super.commonInit() + + diagramView.dataSource = self +// diagramView.separatorWidh = 8 +// diagramView.separatorColor = .black +// diagramView.startAngle = .pi * 0.75 +// diagramView.endAngle = 2 * .pi + .pi * 0.75 + } + + override func modelWasSet() { + super.modelWasSet() + diagramView.backgroundColor = modelColor?.withAlphaComponent(0.1) + } + + override func pageChanged() { + super.pageChanged() + diagramView.backgroundColor = modelColor?.withAlphaComponent(0.1) + } +} + +extension A1cView: SMDiagramViewDataSource { + + @objc func numberOfSegmentsIn(diagramView: SMDiagramView) -> Int { + return 2 + } + + func diagramView(_ diagramView: SMDiagramView, colorForSegmentAtIndex index: NSInteger, angle: CGFloat) -> UIColor? { +// return (index == 1) ? a1cColor : variationColor + return modelColor + } + + func diagramView(_ diagramView: SMDiagramView, radiusForSegmentAtIndex index: NSInteger, proportion: CGFloat, angle: CGFloat) -> CGFloat { + return (diagramView.frame.size.height - 2/*diagramView.arcWidth*/) / 2 + } + + func diagramView(_ diagramView: SMDiagramView, lineWidthForSegmentAtIndex index: NSInteger, angle: CGFloat) -> CGFloat { + //not called for SMDiagramViewModeSegment + return 2.0 + } +} diff --git a/nightguard/AlarmRule.swift b/nightguard/AlarmRule.swift index dd3c57e3..a462e761 100644 --- a/nightguard/AlarmRule.swift +++ b/nightguard/AlarmRule.swift @@ -62,7 +62,19 @@ class AlarmRule { static var isSmartSnoozeEnabled = UserDefaultsValue(key: "smartSnoozeEnabled", default: true) .group(UserDefaultsValueGroups.GroupNames.watchSync) .group(UserDefaultsValueGroups.GroupNames.alarm) + + static var isPersistentHighEnabled = UserDefaultsValue(key: "persistentHighEnabled", default: false) + .group(UserDefaultsValueGroups.GroupNames.watchSync) + .group(UserDefaultsValueGroups.GroupNames.alarm) + static var persistentHighMinutes = UserDefaultsValue(key: "persistentHighMinutes", default: 30) + .group(UserDefaultsValueGroups.GroupNames.watchSync) + .group(UserDefaultsValueGroups.GroupNames.alarm) + + static var persistentHighUpperBound = UserDefaultsValue(key: "persistentHighUpperBound", default: 250) + .group(UserDefaultsValueGroups.GroupNames.watchSync) + .group(UserDefaultsValueGroups.GroupNames.alarm) + /* * Returns true if the alarm should be played. * Snooze is true if the Alarm has been manually deactivated. @@ -130,6 +142,24 @@ class AlarmRule { } if isTooHigh { + + if isPersistentHighEnabled.value { + if currentReading.value < persistentHighUpperBound.value { + + // if all the previous readings (for the defined minutes are high, we'll consider it a persistent high) + let lastReadings = bloodValues.lastXMinutes(persistentHighMinutes.value) + + // we should have at least a reading in 10 minutes for considering a persistent high + if !lastReadings.isEmpty && (lastReadings.count >= (persistentHighMinutes.value / 10)) { + if lastReadings.allSatisfy({ AlarmRule.isTooHigh($0.value) }) { + return "Persistent High BG" + } else { + return nil + } + } + } + } + return "High BG" } else if isTooLow { return "Low BG" diff --git a/nightguard/AlarmViewController.swift b/nightguard/AlarmViewController.swift index c0cf2bf0..968f9757 100644 --- a/nightguard/AlarmViewController.swift +++ b/nightguard/AlarmViewController.swift @@ -20,8 +20,6 @@ class AlarmViewController: CustomFormViewController { fileprivate let MAX_ALERT_BELOW_VALUE : Float = 200 fileprivate let MIN_ALERT_BELOW_VALUE : Float = 50 - fileprivate let SNAP_INCREMENT : Float = 10 // or change it to 5? - override var supportedInterfaceOrientations : UIInterfaceOrientationMask { return UIInterfaceOrientationMask.portrait } @@ -39,10 +37,10 @@ class AlarmViewController: CustomFormViewController { override func constructForm() { - aboveSliderRow = createSliderRow(initialValue: AlarmRule.alertIfAboveValue.value, minimumValue: MIN_ALERT_ABOVE_VALUE, maximumValue: MAX_ALERT_ABOVE_VALUE) + aboveSliderRow = SliderRow.glucoseLevelSlider(initialValue: AlarmRule.alertIfAboveValue.value, minimumValue: MIN_ALERT_ABOVE_VALUE, maximumValue: MAX_ALERT_ABOVE_VALUE) aboveSliderRow.cell.slider.addTarget(self, action: #selector(onSliderValueChanged(slider:event:)), for: .valueChanged) - belowSliderRow = createSliderRow(initialValue: AlarmRule.alertIfBelowValue.value, minimumValue: MIN_ALERT_BELOW_VALUE, maximumValue: MAX_ALERT_BELOW_VALUE) + belowSliderRow = SliderRow.glucoseLevelSlider(initialValue: AlarmRule.alertIfBelowValue.value, minimumValue: MIN_ALERT_BELOW_VALUE, maximumValue: MAX_ALERT_BELOW_VALUE) belowSliderRow.cell.slider.addTarget(self, action: #selector(onSliderValueChanged(slider:event:)), for: .valueChanged) @@ -89,6 +87,28 @@ class AlarmViewController: CustomFormViewController { } } + <<< ButtonRowWithDynamicDetails("Persistent High") { row in + row.controllerProvider = { return PersistentHighViewController() } + row.detailTextProvider = { + + let urgentHighInMgdl = AlarmRule.persistentHighUpperBound.value + let urgentHigh = UnitsConverter.toDisplayUnits("\(urgentHighInMgdl)") + let units = UserDefaultsRepository.units.value.description + let urgentHighWithUnits = "\(urgentHigh) \(units)" + + if AlarmRule.isPersistentHighEnabled.value { + if #available(iOS 11.0, *) { + return "Alerts when BG remains high for more than \(AlarmRule.persistentHighMinutes.value) minutes or exceeds the urgent high value (\(urgentHighWithUnits))." + } else { + // single line, as iOS 10 doesn't expand cell for more lines + return "\(AlarmRule.persistentHighMinutes.value) minutes (< \(urgentHighWithUnits))" + } + } else { + return "Off" + } + } + } + <<< ButtonRowWithDynamicDetails("Low Prediction") { row in row.controllerProvider = { return LowPredictionViewController() } row.detailTextProvider = { @@ -174,34 +194,6 @@ class AlarmViewController: CustomFormViewController { } } - private func createSliderRow(initialValue: Float, minimumValue: Float, maximumValue: Float) -> SliderRow { - - return SliderRow() { row in - row.value = Float(UnitsConverter.toDisplayUnits("\(initialValue)"))! - }.cellSetup { [weak self] cell, row in - guard let self = self else { return } - // row.shouldHideValue = true - - let minimumValue = Float(UnitsConverter.toDisplayUnits("\(minimumValue)"))! - let maximumValue = Float(UnitsConverter.toDisplayUnits("\(maximumValue)"))! - let snapIncrement = (UserDefaultsRepository.units.value == .mgdl) ? self.SNAP_INCREMENT : 0.1 - - let steps = (maximumValue - minimumValue) / snapIncrement - row.steps = UInt(steps.rounded()) - cell.slider.minimumValue = minimumValue - cell.slider.maximumValue = maximumValue - row.displayValueFor = { value in - guard let value = value else { return "" } - let units = UserDefaultsRepository.units.value.description - return String("\(value.cleanValue) \(units)") - } - - // fixed width for value label - let widthConstraint = NSLayoutConstraint(item: cell.valueLabel, attribute: NSLayoutConstraint.Attribute.width, relatedBy: NSLayoutConstraint.Relation.equal, toItem: nil, attribute: NSLayoutConstraint.Attribute.notAnAttribute, multiplier: 1, constant: 96) - cell.valueLabel.addConstraints([widthConstraint]) - } - } - private func alertInvalidChange(message: String) { let alertController = UIAlertController(title: "Invalid change", message: message, preferredStyle: .alert) let actionOk = UIAlertAction(title: "OK", style: .default, handler: nil) diff --git a/nightguard/Base.lproj/Main.storyboard b/nightguard/Base.lproj/Main.storyboard index 43dea52d..005bde01 100644 --- a/nightguard/Base.lproj/Main.storyboard +++ b/nightguard/Base.lproj/Main.storyboard @@ -262,13 +262,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + + + + + + + + - + + + + - - - - + + - - - + - - + + @@ -485,16 +500,17 @@ + + - - + diff --git a/nightguard/BasicStats.swift b/nightguard/BasicStats.swift new file mode 100644 index 00000000..bea9f418 --- /dev/null +++ b/nightguard/BasicStats.swift @@ -0,0 +1,254 @@ +// +// BasicStats.swift +// nightguard +// +// Created by Florian Preknya on 3/1/19. +// Copyright © 2019 private. All rights reserved. +// + +import Foundation + +/** + The stats values calculated by considering all the readings for a given (recent) period. + */ +struct BasicStats { + + // The time period of the stats data (most recent data) + enum Period: CustomStringConvertible { + case last24h + case last8h + case today + case yesterday + case todayAndYesterday + + var description: String { + + switch self { + case .last24h: + return "Last 24h" + case .last8h: + return "Last 8h" + case .today: + return "Today" + case .yesterday: + return "Yesterday" + case .todayAndYesterday: + return "Today & Yesterday" + } + } + + // how many minutes are in the period? + var minutes: Int { + switch self { + case .last24h: + return 24 * 60 + case .last8h: + return 8 * 60 + case .today: + let secondsFromStartOfDay = Date().timeIntervalSince( + Calendar(identifier: .gregorian).startOfDay(for: Date()) + ) + return Int(secondsFromStartOfDay / 60) + case .yesterday: + return 24 * 60 + case .todayAndYesterday: + return Period.today.minutes + 24 * 60 + } + } + + // the period readings + var readings: [BloodSugar] { + switch self { + case .last24h: + let now = Date() + + // get today's data + let todaysReadings = NightscoutCacheService.singleton.getTodaysBgData() + + // get yesterday's data, the ones that are newer than 24h + let yesterdaysReadings = NightscoutCacheService.singleton.getYesterdaysBgData() + let yesterdaysReadingsNewerThan24h = yesterdaysReadings.suffix(yesterdaysReadings.count) { $0.date > now } + + return yesterdaysReadingsNewerThan24h + todaysReadings + + case .last8h: + let eightHoursBefore = Date().addingTimeInterval(-8 * 60 * 60) + + // get today's data + let todaysReadings = NightscoutCacheService.singleton.getTodaysBgData() + let todaysReadingsNewerThan8h = todaysReadings.suffix(todaysReadings.count) { $0.date > eightHoursBefore } + if todaysReadingsNewerThan8h.count < todaysReadings.count { + return todaysReadingsNewerThan8h + } else { + + // hack for yesterday 8 hours before (because yesterday dates are changed for today - a trick for displaying them in the graph) - we'll have to add 16h for getting the corresponding readings + let eightHoursBeforeForYesterday = eightHoursBefore.addingTimeInterval(24 * 60 * 60) + let yesterdaysReadings = NightscoutCacheService.singleton.getYesterdaysBgData() + let yesterdaysReadingsNewerThan8h = yesterdaysReadings.suffix(yesterdaysReadings.count) { $0.date > eightHoursBeforeForYesterday } + + return yesterdaysReadingsNewerThan8h + todaysReadingsNewerThan8h + } + + case .today: + return NightscoutCacheService.singleton.getTodaysBgData() + case .yesterday: + return NightscoutCacheService.singleton.getYesterdaysBgData() + case .todayAndYesterday: + return NightscoutCacheService.singleton.getYesterdaysBgData() + NightscoutCacheService.singleton.getTodaysBgData() + } + } + + } + let period: Period + + let averageGlucose: Float + let a1c: Float + let standardDeviation: Float + let coefficientOfVariation: Float + + let readingsCount: Int + var readingsMaximumCount: Int { + return max(period.minutes / 5, readingsCount) // one reading each 5 minutes + } + var readingsPercentage: Float { + return (Float(readingsCount) / Float(readingsMaximumCount)).roundTo3f.clamped(to: 0...1) + } + + let invalidValuesCount: Int + var invalidValuesPercentage: Float { + return (readingsCount != 0) ? (Float(invalidValuesCount) / Float(readingsCount)).roundTo3f : 0 + } + + let lowValuesCount: Int + var lowValuesPercentage: Float { + if lowValuesCount == 0 { + return 0.0 + } else { + // to avoid situations when the sum of high, low & in range is not exactly 100% + return Float(1.0) - highValuesPercentage - inRangeValuesPercentage + } +// let validReadingsCount = readingsCount - invalidValuesCount +// return (validReadingsCount != 0) ? (Float(lowValuesCount) / Float(validReadingsCount)).roundTo3f : 0 + } + + let highValuesCount: Int + var highValuesPercentage: Float { + let validReadingsCount = readingsCount - invalidValuesCount + return (validReadingsCount != 0) ? (Float(highValuesCount) / Float(validReadingsCount)).roundTo3f : 0 + } + + let inRangeValuesCount: Int + var inRangeValuesPercentage: Float { + let validReadingsCount = readingsCount - invalidValuesCount + return (validReadingsCount != 0) ? (Float(inRangeValuesCount) / Float(validReadingsCount)).roundTo3f : 0 + } + + /// Returns true if the stats are actual, with respect for the input data (contains the most recent readings & the current upper-lower bounds). + var isUpToDate: Bool { + return + self.period.readings.last == self.latestReading && + self.upperBound == UserDefaultsRepository.upperBound.value && + self.lowerBound == UserDefaultsRepository.lowerBound.value + } + + // store some relevant data about the stats input data (to be able to tell later if the stats are "up to date") + fileprivate let latestReading: BloodSugar? + fileprivate let upperBound: Float + fileprivate let lowerBound: Float + + init(period: Period = Period.last24h) { + + self.period = period + + // get the readings + let readings = period.readings + + // get the upper/lower bounds + self.upperBound = UserDefaultsRepository.upperBound.value + self.lowerBound = UserDefaultsRepository.lowerBound.value + + self.readingsCount = readings.count + self.latestReading = readings.last + + var invalidValuesCount = 0, lowValuesCount = 0, highValuesCount = 0, inRangeValuesCount = 0 + var totalGlucoseCount: Float = 0 + + var validReadings: [BloodSugar] = [] + for reading in readings { + guard reading.isValid else { + invalidValuesCount += 1 + continue + } + + validReadings.append(reading) + + if reading.value <= lowerBound { + lowValuesCount += 1 + } else if reading.value >= upperBound { + highValuesCount += 1 + } else { + inRangeValuesCount += 1 + } + + totalGlucoseCount += reading.value + } + + self.invalidValuesCount = invalidValuesCount + self.lowValuesCount = lowValuesCount + self.highValuesCount = highValuesCount + self.inRangeValuesCount = inRangeValuesCount + + self.averageGlucose = totalGlucoseCount / Float(readings.count - invalidValuesCount) + self.a1c = (46.7 + self.averageGlucose) / 28.7 + self.standardDeviation = Float(validReadings.map { Double($0.value) }.standardDeviation) + self.coefficientOfVariation = (self.averageGlucose != 0) ? (self.standardDeviation / self.averageGlucose).roundTo3f : Float.nan + } +} + +extension BasicStats { + + var formattedInRangeValuesPercentage: String? { + return formattedPercent(inRangeValuesPercentage) + } + + var formattedLowValuesPercentage: String? { + return formattedPercent(lowValuesPercentage) + } + + var formattedHighValuesPercentage: String? { + return formattedPercent(highValuesPercentage) + } + + var formattedAverageGlucose: String? { + return formattedUnits(averageGlucose) + } + + var formattedA1c: String? { + return a1c.isNaN ? nil : "\(a1c.round(to: 1).cleanValue)%" + } + + var formattedStandardDeviation: String? { + return formattedUnits(standardDeviation) + } + + var formattedCoefficientOfVariation: String? { + return formattedPercent(coefficientOfVariation) + } + + var formattedReadingsPercentage: String? { + return formattedPercent(readingsPercentage) + } + + var formattedInvalidValuesPercentage: String? { + return formattedPercent(invalidValuesPercentage) + } + + private func formattedPercent(_ value: Float) -> String? { + return value.isNaN ? nil : "\((value * 100).cleanValue)%" + } + + private func formattedUnits(_ value: Float) -> String? { + return value.isNaN ? nil : + UnitsConverter.toDisplayUnits("\(value)") + " \(UserDefaultsRepository.units.value.description)" + } +} diff --git a/nightguard/BasicStatsControl.swift b/nightguard/BasicStatsControl.swift new file mode 100644 index 00000000..b0d0c165 --- /dev/null +++ b/nightguard/BasicStatsControl.swift @@ -0,0 +1,287 @@ +// +// BasicStatsControl.swift +// nightguard +// +// Created by Florian Preknya on 3/18/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +/** + The stats page contains information about a statistic feature (name-value, other attributes used for drawing) + */ +struct StatsPage { + var name: String + var value: Any? + var formattedValue: String? + var detail: String? + var color: UIColor? + + init(name: String, value: Any? = nil, formattedValue: String? = nil, detail: String? = nil, color: UIColor? = nil) { + self.name = name + self.value = value + self.formattedValue = formattedValue + self.detail = detail + self.color = color + } +} + +/** + The base stats view class, a round view that takes a BasicStats instance as model and displays a segment-like chart on margins (optional & very configurable) and property-value labels in the center (the curent stats page). The stats views can contain multiple pages; pages are turned when the user taps the view. + */ +class BasicStatsControl: TouchReportingView { + + var model: BasicStats? { + didSet { +// diagramView.reloadData() + + // reveal! + UIView.animate(withDuration: 0.8) { [weak self] in + self?.diagramView.alpha = 1 + } + + modelWasSet() + } + } + + var pages: [StatsPage] = [] + + var currentPageIndex: Int = -1 { + didSet { + pageChanged() + diagramView.reloadData() + } + } + + var currentPage: StatsPage? { + guard currentPageIndex >= 0 && currentPageIndex < pages.count else { + return nil + } + + return pages[currentPageIndex] + } + + lazy var diagramView: SMDiagramView = createDiagramView() + + var valueLabel: UILabel? { + return (diagramView.titleView as? UIStackView)?.arrangedSubviews.first as? UILabel + } + var nameLabel: UILabel? { + return (diagramView.titleView as? UIStackView)?.arrangedSubviews.last as? UILabel + } + + fileprivate var alternateValueTimer: Timer? + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + override func commonInit() { + super.commonInit() + + clipsToBounds = true + backgroundColor = UIColor.darkGray.withAlphaComponent(0.3) + + onTouchStarted = { [unowned self] in + self.diagramView.titleView?.alpha = 0.5 + } + + onTouchEnded = { [unowned self] in + self.diagramView.titleView?.alpha = 1 + } + + onTouchUpInside = { [unowned self] in + self.changePage() + } + + // add to superview + addSubview(diagramView) + diagramView.pin(to: self) + + diagramView.titleView = createTitleView() + diagramView.isUserInteractionEnabled = false + + let isSmallDevice = DeviceSize().isSmall + nameLabel?.numberOfLines = 2 + nameLabel?.preferredMaxLayoutWidth = isSmallDevice ? 56 : 64 + valueLabel?.numberOfLines = 2 + valueLabel?.preferredMaxLayoutWidth = isSmallDevice ? 56 : 64 + + + // hidden until model is set + diagramView.alpha = 0 + } + + func changePage() { + if pages.count > 1 { + self.currentPageIndex = (self.currentPageIndex + 1) % pages.count + } + } + + func modelWasSet() { + + // get current page name + let currentPageName = currentPage?.name + + // recreate pages + pages = createPages() + + // restore current page + if pages.isEmpty { + currentPageIndex = -1 + } else { + if let currentPageName = currentPageName { + currentPageIndex = pages.firstIndex(where: { $0.name == currentPageName }) ?? 0 + } else { + currentPageIndex = 0 + } + } + + // update title + updateTitleView(name: currentPage?.name, value: currentPage?.formattedValue, detail: currentPage?.detail) + } + + func createPages() -> [StatsPage] { + + // override in subclasses + return [ + StatsPage(name: "") + ] + } + + func pageChanged() { + + // update title + updateTitleView(name: currentPage?.name, value: currentPage?.formattedValue, detail: currentPage?.detail) + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.bounds.size.height / 2 + } + + func updateTitleView(name: String?, value: String?, detail: String? = nil) { + + nameLabel?.text = name + updateValueLabel(value) + + alternateValueTimer?.invalidate() + alternateValueTimer = nil + + if detail != nil { + + alternateValueTimer = Timer.schedule(1.5) { [weak self] _ in + guard let valueLabel = self?.valueLabel else { return } + UIView.transition(with: valueLabel, duration: 0.4, options: .transitionFlipFromTop, animations: { [weak self] in + self?.updateValueLabel(detail, asDetail: true) + }) + self?.updateTitleViewSize() + + self?.alternateValueTimer = Timer.schedule(1.5) { [weak self] _ in + UIView.transition(with: valueLabel, duration: 0.4, options: .transitionFlipFromBottom, animations: { [weak self] in + self?.updateValueLabel(value, asDetail: false) + }) + self?.updateTitleViewSize() + } + } + } + + updateTitleViewSize() + } + + private func createDiagramView() -> SMDiagramView { + + let diagramView = SMDiagramView() + diagramView.backgroundColor = .clear + + // configure + diagramView.minProportion = 0.009 + diagramView.diagramViewMode = .arc // or .segment + diagramView.diagramOffset = .zero + diagramView.radiusOfSegments = 30.0 + // diagramView.radiusOfViews = 50.0 + diagramView.arcWidth = 8.0 //Ignoring for SMtargetDiagramViewMode.segment + diagramView.colorOfSegments = .clear + // targetDiagramView.viewsOffset = .zero + diagramView.separatorWidh = 0.0 + diagramView.separatorColor = .clear + + return diagramView + } + + private func createTitleView() -> UIView { + + let isSmallDevice = DeviceSize().isSmall + + let valueLabel = UILabel() + valueLabel.text = "" + valueLabel.textAlignment = .center + valueLabel.textColor = UIColor.white + valueLabel.font = UIFont.boldSystemFont(ofSize: isSmallDevice ? 13 : 15) + + let nameLabel = UILabel() + nameLabel.text = "" + nameLabel.textAlignment = .center + nameLabel.textColor = UIColor.white.withAlphaComponent(0.5) + nameLabel.font = UIFont.systemFont(ofSize: isSmallDevice ? 8 : 9) + + let stackView = UIStackView(arrangedSubviews: [valueLabel, nameLabel]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = isSmallDevice ? 2 : 4 + + return stackView + } + + func updateValueLabel(_ value: String?, asDetail: Bool = false) { + + guard let valueLabel = self.valueLabel else { + return + } + + let isSmallDevice = DeviceSize().isSmall + + valueLabel.text = value + if asDetail { + valueLabel.textColor = UIColor.white.withAlphaComponent(0.8) + valueLabel.font = UIFont.systemFont(ofSize: isSmallDevice ? 13 : 15) + } else { + valueLabel.textColor = UIColor.white + valueLabel.font = UIFont.boldSystemFont(ofSize: isSmallDevice ? 13 : 15) + } + } + + private func updateTitleViewSize() { + guard let titleView = diagramView.titleView else { + return + } + + let width = self.bounds.width + let height = titleView.systemLayoutSizeFitting(UILayoutFittingCompressedSize).height + titleView.bounds = CGRect(origin: CGPoint.zero, size: CGSize(width: width, height: height)) + } + + func formattedDuration(fromReadingsCount readingsCount: Int) -> String { + + let totalMinutes = readingsCount * 5 // 5 minutes each reading + + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + if hours == 0 { + return "\(minutes)m" + } else if minutes == 0 { + return "\(hours)h" + } else { + return "\(hours)h \(minutes)m" + } + } +} diff --git a/nightguard/BasicStatsPanelView.swift b/nightguard/BasicStatsPanelView.swift new file mode 100644 index 00000000..6a30145a --- /dev/null +++ b/nightguard/BasicStatsPanelView.swift @@ -0,0 +1,53 @@ +// +// BasicStatsPanelView.swift +// nightguard +// +// Created by Florian Preknya on 3/10/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +/** + The stats panel that contains all the rounded stats views. + */ +class BasicStatsPanelView: XibLoadedView { + + var model: BasicStats? { + didSet { + glucoseDistributionView.model = model + a1cView.model = model + readingsStatsView.model = model + periodSelectorView.model = model + } + } + + @IBOutlet weak var glucoseDistributionView: GlucoseDistributionView! + @IBOutlet weak var a1cView: A1cView! + @IBOutlet weak var readingsStatsView: ReadingsStatsView! + @IBOutlet weak var periodSelectorView: StatsPeriodSelectorView! + + override func commonInit() { + super.commonInit() + + periodSelectorView.onPeriodChangeRequest = { period in + self.model = BasicStats(period: period) + } + + defer { + self.model = BasicStats(period: .last24h) + } + } + + func updateModel() { + if let model = self.model, model.isUpToDate { + + // do nothing, the model contains already the most recent reading + } else { + + // (re)create the model + self.model = BasicStats(period: self.model?.period ?? .last24h) + } + } +} + diff --git a/nightguard/BasicStatsPanelView.xib b/nightguard/BasicStatsPanelView.xib new file mode 100644 index 00000000..7f894df1 --- /dev/null +++ b/nightguard/BasicStatsPanelView.xib @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nightguard/Comparable+Extensions.swift b/nightguard/Comparable+Extensions.swift new file mode 100644 index 00000000..eadf31f4 --- /dev/null +++ b/nightguard/Comparable+Extensions.swift @@ -0,0 +1,15 @@ +// +// Comparable+Extensions.swift +// nightguard +// +// Created by Florian Preknya on 3/22/19. +// Copyright © 2019 private. All rights reserved. +// + +import Foundation + +extension Comparable { + func clamped(to limits: ClosedRange) -> Self { + return min(max(self, limits.lowerBound), limits.upperBound) + } +} diff --git a/nightguard/CustomFormViewController.swift b/nightguard/CustomFormViewController.swift index 0066abb2..ed903b1c 100644 --- a/nightguard/CustomFormViewController.swift +++ b/nightguard/CustomFormViewController.swift @@ -222,3 +222,32 @@ extension SelectorViewController { } } } + +extension SliderRow { + + class func glucoseLevelSlider(initialValue: Float, minimumValue: Float, maximumValue: Float, snapIncrementForMgDl: Float = 10.0) -> SliderRow { + + return SliderRow() { row in + row.value = Float(UnitsConverter.toDisplayUnits("\(initialValue)"))! + }.cellSetup { cell, row in + + let minimumValue = Float(UnitsConverter.toDisplayUnits("\(minimumValue)"))! + let maximumValue = Float(UnitsConverter.toDisplayUnits("\(maximumValue)"))! + let snapIncrement = (UserDefaultsRepository.units.value == .mgdl) ? snapIncrementForMgDl : 0.1 + + let steps = (maximumValue - minimumValue) / snapIncrement + row.steps = UInt(steps.rounded()) + cell.slider.minimumValue = minimumValue + cell.slider.maximumValue = maximumValue + row.displayValueFor = { value in + guard let value = value else { return "" } + let units = UserDefaultsRepository.units.value.description + return String("\(value.cleanValue) \(units)") + } + + // fixed width for value label + let widthConstraint = NSLayoutConstraint(item: cell.valueLabel, attribute: NSLayoutConstraint.Attribute.width, relatedBy: NSLayoutConstraint.Relation.equal, toItem: nil, attribute: NSLayoutConstraint.Attribute.notAnAttribute, multiplier: 1, constant: 96) + cell.valueLabel.addConstraints([widthConstraint]) + } + } +} diff --git a/nightguard/DeviceSize.swift b/nightguard/DeviceSize.swift index 9b1d57b5..9e323cfe 100644 --- a/nightguard/DeviceSize.swift +++ b/nightguard/DeviceSize.swift @@ -78,3 +78,9 @@ enum DeviceSize { } } } + +extension DeviceSize { + var isSmall: Bool { + return self == .iPhone4 || self == .iPhone5 + } +} diff --git a/nightguard/FloatExtension.swift b/nightguard/FloatExtension.swift index 98c55e15..80043b9b 100644 --- a/nightguard/FloatExtension.swift +++ b/nightguard/FloatExtension.swift @@ -16,4 +16,13 @@ extension Float { ? String(format: "%5.0f", self).trimmingCharacters(in: CharacterSet.whitespaces) : String(format: "%5.1f", self).trimmingCharacters(in: CharacterSet.whitespaces) } + + var roundTo3f: Float { + return round(to: 3) + } + + func round(to places: Int) -> Float { + let divisor = pow(10.0, Float(places)) + return (divisor * self).rounded() / divisor + } } diff --git a/nightguard/GlucoseDistributionView.swift b/nightguard/GlucoseDistributionView.swift new file mode 100644 index 00000000..b8688594 --- /dev/null +++ b/nightguard/GlucoseDistributionView.swift @@ -0,0 +1,121 @@ +// +// GlucoseDistributionView.swift +// nightguard +// +// Created by Florian Preknya on 3/18/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +/** + The stats view that displays the BG distribution chart & time spent in each individual ranges. + */ +class GlucoseDistributionView: BasicStatsControl { + + override func createPages() -> [StatsPage] { + + var lowDuration: String? + if let lowValuesCount = model?.lowValuesCount, lowValuesCount > 0 { + lowDuration = formattedDuration(fromReadingsCount: lowValuesCount) + } + + var highDuration: String? + if let highValuesCount = model?.highValuesCount, highValuesCount > 0 { + highDuration = formattedDuration(fromReadingsCount: highValuesCount) + } + + return [ + StatsPage(name: "In Range", value: model?.inRangeValuesPercentage, formattedValue: model?.formattedInRangeValuesPercentage, color: .green), + StatsPage(name: "Low", value: model?.lowValuesPercentage, formattedValue: model?.formattedLowValuesPercentage, detail: lowDuration, color: .red), + StatsPage(name: "High", value: model?.highValuesPercentage, formattedValue: model?.formattedHighValuesPercentage, detail: highDuration, color: .yellow), + StatsPage(name: "") + ] + } + + override func commonInit() { + super.commonInit() + + diagramView.dataSource = self + } + + override func pageChanged() { + super.pageChanged() + + // display segments for last page + diagramView.diagramViewMode = (currentPageIndex == (pages.count - 1)) ? .segment : .arc + } +} + +extension GlucoseDistributionView: SMDiagramViewDataSource { + + @objc func numberOfSegmentsIn(diagramView: SMDiagramView) -> Int { + return pages.count - 1 + } + + func diagramView(_ diagramView: SMDiagramView, proportionForSegmentAtIndex index: NSInteger) -> CGFloat { + guard let value = pages[index].value as? Float else { + return 0 + } + + return CGFloat(value) + } + + func diagramView(_ diagramView: SMDiagramView, colorForSegmentAtIndex index: NSInteger, angle: CGFloat) -> UIColor? { + + var color = pages[index].color + + // if diagramView.diagramViewMode == .arc { + if index != currentPageIndex { + color = color?.withAlphaComponent(0.7) + } + // } + + return color + } + + func diagramView(_ diagramView: SMDiagramView, viewForSegmentAtIndex index: NSInteger, colorOfSegment color:UIColor?, angle: CGFloat) -> UIView? { + + if diagramView.diagramViewMode == .arc { + return nil + } else { + guard let percentage = pages[index].value as? Float, percentage > 0.3 else { + // not big enough! + return nil + } + + let percentsLabel = UILabel() + percentsLabel.text = pages[index].formattedValue + percentsLabel.textColor = UIColor.white.withAlphaComponent(0.7) + percentsLabel.clipsToBounds = false + percentsLabel.layer.shadowColor = UIColor.black.cgColor + percentsLabel.layer.shadowOpacity = 1.0 + percentsLabel.layer.shadowOffset = CGSize(width: 2, height: 2) + + percentsLabel.font = UIFont.boldSystemFont(ofSize: 9) + percentsLabel.sizeToFit() + return percentsLabel + } + } + + func diagramView(_ diagramView: SMDiagramView, offsetForView view: UIView?, atIndex index: NSInteger, angle: CGFloat) -> CGPoint { + return .zero + } + + func diagramView(_ diagramView: SMDiagramView, radiusForView view: UIView?, atIndex index: NSInteger, radiusOfSegment radius: CGFloat, angle: CGFloat) -> CGFloat { + return diagramView.frame.size.height / 4 + } + + func diagramView(_ diagramView: SMDiagramView, radiusForSegmentAtIndex index: NSInteger, proportion: CGFloat, angle: CGFloat) -> CGFloat { + if diagramView.diagramViewMode == .arc { + return (diagramView.frame.size.height - diagramView.arcWidth) / 2 + ((index == currentPageIndex) ? 0 : 1) + } else { + return diagramView.frame.size.height + } + } + + func diagramView(_ diagramView: SMDiagramView, lineWidthForSegmentAtIndex index: NSInteger, angle: CGFloat) -> CGFloat { + //not called for SMDiagramViewModeSegment + return (index == currentPageIndex) ? 8.0 : 6.0 + } +} diff --git a/nightguard/MainViewController.swift b/nightguard/MainViewController.swift index 491e8098..f81ad592 100644 --- a/nightguard/MainViewController.swift +++ b/nightguard/MainViewController.swift @@ -21,7 +21,6 @@ class MainViewController: UIViewController { @IBOutlet weak var batteryLabel: UILabel! @IBOutlet weak var iobLabel: UILabel! @IBOutlet weak var snoozeButton: UIButton! - @IBOutlet weak var volumeContainerView: UIView! @IBOutlet weak var spriteKitView: UIView! @IBOutlet weak var errorPanelView: UIView! @IBOutlet weak var errorLabel: UILabel! @@ -29,6 +28,9 @@ class MainViewController: UIViewController { @IBOutlet weak var bgStackView: UIStackView! @IBOutlet weak var nightscoutButton: UIButton! + @IBOutlet weak var nightscoutButtonPanelView: UIView! + @IBOutlet weak var statsPanelView: BasicStatsPanelView! + // the way that has already been moved during a pan gesture var oldXTranslation : CGFloat = 0 @@ -38,6 +40,9 @@ class MainViewController: UIViewController { // check every 30 Seconds whether new bgvalues should be retrieved let timeInterval: TimeInterval = 30.0 + // basic stats for the last 24 hours + var basicStats: BasicStats? + override var supportedInterfaceOrientations : UIInterfaceOrientationMask { return UIInterfaceOrientationMask.portrait } @@ -45,20 +50,11 @@ class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - // Embed the Volume Slider View - // This way the system volume can be - // controlled by the user - let volumeView = MPVolumeView(frame: volumeContainerView.bounds) - volumeView.backgroundColor = UIColor.black - volumeView.tintColor = UIColor.gray - volumeContainerView.addSubview(volumeView) - // add an observer to resize the MPVolumeView when displayed on e.g. 4.7" iPhone - volumeContainerView.addObserver(self, forKeyPath: "bounds", options: [], context: nil) - volumeContainerView.isHidden = true - snoozeButton.titleLabel?.numberOfLines = 0 snoozeButton.titleLabel?.lineBreakMode = .byWordWrapping snoozeButton.backgroundColor = UIColor.darkGray.withAlphaComponent(0.3) + snoozeButton.titleLabel?.font = UIFont.systemFont(ofSize: DeviceSize().isSmall ? 24 : 27) + snoozeButton.titleLabel?.textAlignment = .center // Initialize the ChartScene chartScene = ChartScene(size: CGSize(width: spriteKitView.bounds.width, height: spriteKitView.bounds.height), @@ -87,6 +83,7 @@ class MainViewController: UIViewController { let nightscoutImage = UIImage(named: "Nightscout")?.withRenderingMode(.alwaysTemplate) nightscoutButton.setImage(nightscoutImage, for: .normal) nightscoutButton.backgroundColor = UIColor.darkGray.withAlphaComponent(0.3) + nightscoutButtonPanelView.backgroundColor = .black // stop timer when app enters in background, start is again when becomes active NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground(_:)), name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil) @@ -102,6 +99,13 @@ class MainViewController: UIViewController { AlarmRule.onSnoozeTimestampChanged = { [weak self] in self?.evaluateAlarmActivationState() } + + UserDefaultsRepository.upperBound.observeChanges { [weak self] _ in + self?.updateBasicStats() + } + UserDefaultsRepository.lowerBound.observeChanges { [weak self] _ in + self?.updateBasicStats() + } } override func viewWillAppear(_ animated: Bool) { @@ -109,13 +113,23 @@ class MainViewController: UIViewController { self.navigationController?.setNavigationBarHidden(true, animated: animated) showHideRawBGPanel() + + // show/hide the stats panel, using user preference value + let statsShouldBeHidden = !UserDefaultsRepository.showStats.value + if statsPanelView.isHidden != statsShouldBeHidden { + statsPanelView.isHidden = statsShouldBeHidden + + view.setNeedsLayout() + view.layoutIfNeeded() + + if !statsPanelView.isHidden { + DispatchQueue.main.async { [unowned self] in + self.statsPanelView.updateModel() + } + } + } } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillAppear(animated) - self.navigationController?.setNavigationBarHidden(false, animated: animated) - } - + override func viewDidAppear(_ animated: Bool) { // Start immediately so that the current time gets displayed at once @@ -133,6 +147,12 @@ class MainViewController: UIViewController { // keep the nightscout button round nightscoutButton.layer.cornerRadius = nightscoutButton.bounds.size.width / 2 + nightscoutButtonPanelView.layer.cornerRadius = nightscoutButtonPanelView.bounds.size.width / 2 + + DispatchQueue.main.async { [unowned self] in + self.chartScene.size = CGSize(width: self.spriteKitView.bounds.width, height: self.spriteKitView.bounds.height) + self.loadAndPaintChartData(forceRepaint: true) + } } override func touchesBegan(_ touches: Set, with event: UIEvent?) { @@ -178,20 +198,6 @@ class MainViewController: UIViewController { } } - // Resize the MPVolumeView when the parent view changes - // This is needed on an e.g. 4,7" iPhone. Otherwise the MPVolumeView would be too small - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - - let volumeView = MPVolumeView(frame: volumeContainerView.bounds) - volumeView.backgroundColor = UIColor.black - volumeView.tintColor = UIColor.gray - - for view in volumeContainerView.subviews { - view.removeFromSuperview() - } - volumeContainerView.addSubview(volumeView) - } - override var preferredStatusBarStyle : UIStatusBarStyle { return UIStatusBarStyle.lightContent } @@ -272,6 +278,7 @@ class MainViewController: UIViewController { var subtitle = AlarmRule.getAlarmActivationReason(ignoreSnooze: true) var subtitleColor: UIColor = (subtitle != nil) ? .red : .white var showSubtitle = true + let isSmallDevice = DeviceSize().isSmall if subtitle == nil { @@ -296,12 +303,12 @@ class MainViewController: UIViewController { style.lineBreakMode = .byWordWrapping let titleAttributes: [NSAttributedStringKey : Any] = [ - NSAttributedString.Key.font: UIFont.systemFont(ofSize: 32), + NSAttributedString.Key.font: UIFont.systemFont(ofSize: isSmallDevice ? 24 : 27), NSAttributedString.Key.paragraphStyle: style ] let messageAttributes: [NSAttributedStringKey : Any] = [ - NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16), + NSAttributedString.Key.font: UIFont.systemFont(ofSize: isSmallDevice ? 14 : 16), NSAttributedString.Key.foregroundColor: subtitleColor, NSAttributedString.Key.paragraphStyle: style ] @@ -310,7 +317,6 @@ class MainViewController: UIViewController { attString.append(NSAttributedString(string: title, attributes: titleAttributes)) attString.append(NSAttributedString(string: "\n")) attString.append(NSAttributedString(string: subtitle, attributes: messageAttributes)) - snoozeButton.setAttributedTitle(attString, for: .normal) } else { snoozeButton.setAttributedTitle(nil, for: .normal) @@ -391,6 +397,7 @@ class MainViewController: UIViewController { if case .data(let newTodaysData) = result { let cachedYesterdaysData = NightscoutCacheService.singleton.getYesterdaysBgData() self.paintChartData(todaysData: newTodaysData, yesterdaysData: cachedYesterdaysData) + self.updateBasicStats() } } @@ -400,6 +407,7 @@ class MainViewController: UIViewController { if case .data(let newYesterdaysData) = result { let cachedTodaysBgData = NightscoutCacheService.singleton.getTodaysBgData() self.paintChartData(todaysData: cachedTodaysBgData, yesterdaysData: newYesterdaysData) + self.updateBasicStats() } } @@ -430,4 +438,13 @@ class MainViewController: UIViewController { // show raw values panel ONLY if configured so and we have a valid rawbg value! self.rawValuesPanel.isHidden = !UserDefaultsRepository.showRawBG.value || !isValidRawBGValue } + + fileprivate func updateBasicStats() { + + // update the UI +// statsLabel.text = "A1c: \(String(format: "%.1f", basicStats!.a1c))%, in: \(String(format: "%.1f", basicStats!.inRangeValuesPercentage * 100))%" + if !statsPanelView.isHidden { + statsPanelView.updateModel() + } + } } diff --git a/nightguard/PersistentHighViewController.swift b/nightguard/PersistentHighViewController.swift new file mode 100644 index 00000000..34e57af0 --- /dev/null +++ b/nightguard/PersistentHighViewController.swift @@ -0,0 +1,76 @@ +// +// PersistentHighViewController.swift +// nightguard +// +// Created by Florian Preknya on 3/28/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit +import Eureka + +class PersistentHighViewController: CustomFormViewController { + + var urgentHighSliderRow: SliderRow! + + fileprivate let alarmOptions = [15, 20, 30, 45, 60, 90, 120] + private var selectableSection: SelectableSection>! + + override func constructForm() { + + selectableSection = SelectableSection>("Alert when high BG for more than", selectionType: .singleSelection(enableDeselection: true)) + selectableSection.onSelectSelectableRow = { cell, row in + guard let value = row.value else { return } + AlarmRule.persistentHighMinutes.value = value + } + selectableSection.hidden = "$PersistentHighSwitch == false" + + for option in alarmOptions { + selectableSection <<< ListCheckRow("\(option) Minutes") { lrow in + lrow.title = "\(option) Minutes" + lrow.selectableValue = option + lrow.value = (option == AlarmRule.persistentHighMinutes.value) ? option : nil + } + } + + urgentHighSliderRow = SliderRow.glucoseLevelSlider(initialValue: AlarmRule.persistentHighUpperBound.value, minimumValue: AlarmRule.alertIfAboveValue.value, maximumValue: 300) + urgentHighSliderRow.cell.slider.addTarget(self, action: #selector(onSliderValueChanged(slider:event:)), for: .valueChanged) + + let urgentHighSection = Section(header: "Urgent High", footer: "Alerts anytime when the blood glucose raises above this value.") + urgentHighSection <<< urgentHighSliderRow + urgentHighSection.hidden = "$PersistentHighSwitch == false" + + + form +++ Section(header: "", footer: "Alerts when the BG remains high for a longer period. When on, this alert will delay the high BG alert until the period elapsed or until reaching a maximum BG level (urgent high).") + <<< SwitchRow("PersistentHighSwitch") { row in + row.title = "Persistent High" + row.value = AlarmRule.isPersistentHighEnabled.value + }.onChange { row in + guard let value = row.value else { return } + AlarmRule.isPersistentHighEnabled.value = value + } + + +++ selectableSection + +++ urgentHighSection + } + + @objc func onSliderValueChanged(slider: UISlider, event: UIEvent) { + guard let touchEvent = event.allTouches?.first else { return } + + // modify UserDefaultsValue ONLY when slider value change events ended + switch touchEvent.phase { + case .ended: + if slider === urgentHighSliderRow.cell.slider { + + guard let value = urgentHighSliderRow.value else { return } + let mgdlValue = UnitsConverter.toMgdl(value) + + print("Changed (persistent) urgent high slider to \(mgdlValue) \(UserDefaultsRepository.units.value.description)") + AlarmRule.persistentHighUpperBound.value = mgdlValue + } + + default: + break + } + } +} diff --git a/nightguard/PrefsViewController.swift b/nightguard/PrefsViewController.swift index ed47822a..af79a19b 100644 --- a/nightguard/PrefsViewController.swift +++ b/nightguard/PrefsViewController.swift @@ -141,6 +141,14 @@ class PrefsViewController: CustomFormViewController { } +++ Section() + <<< SwitchRow() { row in + row.title = "Show Statistics" + row.value = UserDefaultsRepository.showStats.value + }.onChange { row in + guard let value = row.value else { return } + UserDefaultsRepository.showStats.value = value + } + <<< SwitchRow() { row in row.title = "Show Raw BG and Noise Level" row.value = UserDefaultsRepository.showRawBG.value diff --git a/nightguard/ReadingsStatsView.swift b/nightguard/ReadingsStatsView.swift new file mode 100644 index 00000000..10783ab8 --- /dev/null +++ b/nightguard/ReadingsStatsView.swift @@ -0,0 +1,69 @@ +// +// ReadingsStatsView.swift +// nightguard +// +// Created by Florian Preknya on 3/18/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +/** + The stats view that displays the number of readings in the selected stats period, how many were invalid, etc. + */ +class ReadingsStatsView: BasicStatsControl { + + override func createPages() -> [StatsPage] { + + let invalidValuesPercentage = model?.invalidValuesPercentage ?? 0 + let readingsPercent = (model?.readingsPercentage ?? 0) - invalidValuesPercentage + var pages = [ + StatsPage(name: "Readings", formattedValue: "\(Float(model?.readingsCount ?? 0).cleanValue) / \(model?.readingsMaximumCount ?? 0)"), + StatsPage(name: "Readings %", value: readingsPercent, formattedValue: model?.formattedReadingsPercentage, color: .white) + ] + + if let invalidValuesCount = model?.invalidValuesCount, invalidValuesCount > 0 { + + pages.append( + StatsPage(name: "Invalid readings", value: invalidValuesPercentage, formattedValue: "\(invalidValuesCount)", detail: formattedDuration(fromReadingsCount: invalidValuesCount) ,color: .red) + ) + } + + return pages + } + + override func commonInit() { + super.commonInit() + + diagramView.dataSource = self + } +} + +extension ReadingsStatsView: SMDiagramViewDataSource { + + @objc func numberOfSegmentsIn(diagramView: SMDiagramView) -> Int { + return pages.count - 1 + } + + func diagramView(_ diagramView: SMDiagramView, proportionForSegmentAtIndex index: NSInteger) -> CGFloat { + guard let value = pages[index + 1].value as? Float else { + return 0 + } + + return CGFloat(value) + } + + func diagramView(_ diagramView: SMDiagramView, colorForSegmentAtIndex index: NSInteger, angle: CGFloat) -> UIColor? { + let color = pages[index + 1].color + return (index == 0 || currentPageIndex < 2) ? color?.withAlphaComponent(0.5) : color + } + + func diagramView(_ diagramView: SMDiagramView, radiusForSegmentAtIndex index: NSInteger, proportion: CGFloat, angle: CGFloat) -> CGFloat { + return (diagramView.frame.size.height - diagramView.arcWidth) / 2 + } + + func diagramView(_ diagramView: SMDiagramView, lineWidthForSegmentAtIndex index: NSInteger, angle: CGFloat) -> CGFloat { + //not called for SMDiagramViewModeSegment + return 6.0 + } +} diff --git a/nightguard/SMDiagramView.swift b/nightguard/SMDiagramView.swift new file mode 100755 index 00000000..2b55786f --- /dev/null +++ b/nightguard/SMDiagramView.swift @@ -0,0 +1,440 @@ +// +// SMDiagramView.swift +// SMDiagramView +// +// Created by OLEKSANDR SEMENIUK on 12/1/17. +// Copyright © 2017 OLEKSANDR SEMENIUK. All rights reserved. +// + +import UIKit + +@objc public protocol SMDiagramViewDataSource: class { + + @objc func numberOfSegmentsIn(diagramView: SMDiagramView) -> Int + + @objc optional func diagramView(_ diagramView: SMDiagramView, proportionForSegmentAtIndex index: NSInteger) -> CGFloat + @objc optional func diagramView(_ diagramView: SMDiagramView, colorForSegmentAtIndex index: NSInteger, angle: CGFloat) -> UIColor? + @objc optional func diagramView(_ diagramView: SMDiagramView, viewForSegmentAtIndex index: NSInteger, colorOfSegment color:UIColor?, angle: CGFloat) -> UIView? + @objc optional func diagramView(_ diagramView: SMDiagramView, offsetForView view: UIView?, atIndex index: NSInteger, angle: CGFloat) -> CGPoint + @objc optional func diagramView(_ diagramView: SMDiagramView, radiusForView view: UIView?, atIndex index: NSInteger, radiusOfSegment radius: CGFloat, angle: CGFloat) -> CGFloat + @objc optional func diagramView(_ diagramView: SMDiagramView, radiusForSegmentAtIndex index: NSInteger, proportion: CGFloat, angle: CGFloat) -> CGFloat + @objc optional func diagramView(_ diagramView: SMDiagramView, lineWidthForSegmentAtIndex index: NSInteger, angle: CGFloat) -> CGFloat //not called for SMDiagramViewModeSegment +} + +@objc public enum SMDiagramViewMode: Int +{ + case arc, segment +} + +@objc public class SMDiagramView: UIView +{ + private var models = [SMDiagramViewModel]() + + public var dataSource: SMDiagramViewDataSource? + + private var _emptyView: UIView? + + public var emptyView: UIView? + { + get + { + return _emptyView + } + set + { + _emptyView?.removeFromSuperview() + _emptyView = newValue + if let view = _emptyView + { + addSubview(view) + } + layoutIfNeeded() + } + } + + private var _titleView: UIView? + + public var titleView: UIView? + { + get + { + return _titleView + } + set + { + _titleView?.removeFromSuperview() + _titleView = newValue + if let view = _titleView + { + addSubview(view) + } + layoutIfNeeded() + } + } + + public var minProportion: CGFloat = 0.1 + public var diagramViewMode: SMDiagramViewMode = .arc + public var diagramOffset: CGPoint = .zero + public var radiusOfSegments: CGFloat = 80.0 + public var radiusOfViews: CGFloat = 130.0 + public var arcWidth: CGFloat = 6.0 //Ignoring for SMDiagramViewMode.segment + public var startAngle: CGFloat = -.pi/2 + public var endAngle: CGFloat = 2.0 * .pi - .pi/2.0 + public var colorOfSegments: UIColor = .black + public var viewsOffset: CGPoint = .zero + public var separatorWidh: CGFloat = 1.0 + private var _separatorColor: UIColor + { + get + { + if let color = separatorColor + { + return color + } + if let color = backgroundColor + { + return color + } + return UIColor.white + } + } + public var separatorColor: UIColor? + + private var centerOfCircle: CGPoint + { + get + { + return CGPoint(x: frame.size.width/2.0 + diagramOffset.x, y: frame.size.height/2.0 + diagramOffset.y) + } + } + + override public func draw(_ rect: CGRect) + { + super.draw(rect) + + drawDiagram() + } + + override public func layoutSubviews() + { + super.layoutSubviews() + + updateViewsPositions() + } + + private func drawDiagram() + { + for model in models + { + if let c = UIGraphicsGetCurrentContext() + { + c.addArc(center: centerOfCircle, radius: model.radiusOfSegment, startAngle: model.startAngle, endAngle: model.endAngle, clockwise: false) + c.setLineWidth(model.lineWidth) + if let color = model.color + { + c.setStrokeColor(color.cgColor) + } + c.drawPath(using: .stroke) + } + } + + if diagramViewMode == .arc + { + drawSeparator() + } + } + + private func drawSeparator() + { + if models.count < 2 + { + return + } + + for model in models + { + if let c = UIGraphicsGetCurrentContext() + { + c.addArc(center: centerOfCircle, radius: model.radiusOfSegment, startAngle: model.separatorStartAngle, endAngle: model.separatorEndAngle, clockwise: false) + c.setLineWidth(model.separatorLineWidth) + c.setStrokeColor(_separatorColor.cgColor) + c.drawPath(using: .stroke) + } + } + } + + private func removeViews() + { + for model in models + { + if let view = model.view + { + view.removeFromSuperview() + } + } + + hideEmptyView() + } + + private func updateViewsPositions() + { + for model in models + { + if let view = model.view + { + view.center = CGPoint(x: centerOfCircle.x + model.viewPosition.x, y: centerOfCircle.y + model.viewPosition.y) + } + } + + titleView?.center = centerOfCircle + } + + private func showEmptyView() + { + if let view = emptyView + { + bringSubview(toFront: view) + view.alpha = 1.0 + view.isHidden = false + } + } + + private func hideEmptyView() + { + if let view = emptyView + { + view.alpha = 0.0 + view.isHidden = true + } + } + + private func updateProportions() + { + var additionalSum: CGFloat = 0.0 + var lessSum: CGFloat = 0.0 + var sum: CGFloat = 0.0 + + for model in models + { + sum += model.calculatedProportion + + if model.calculatedProportion < minProportion + { + lessSum += model.calculatedProportion + additionalSum += minProportion - model.calculatedProportion + model.calculatedProportion = minProportion + } + } + + if additionalSum == 0.0 + { + return + } + + let greatSum = sum - lessSum + + for model in models + { + if model.calculatedProportion > minProportion + { + model.calculatedProportion -= (model.calculatedProportion / greatSum) * additionalSum + } + } + + updateProportions() + } + + public func reloadData() + { + removeViews() + + models.removeAll() + + let count = dataSource?.numberOfSegmentsIn(diagramView: self) + + if endAngle > startAngle + { + if let count = count, count > 0 + { + let originalProportion: CGFloat = 1.0/CGFloat(count) + + assert(CGFloat(roundf(Float(originalProportion*100))/100) >= minProportion, "SMDiagramView. 1/count should not be less minProportion\ncount = \(count)\nminProportion = \(minProportion)") + + var result = [SMDiagramViewModel]() + var totalProportion: CGFloat = 0.0 + + for i in 0 ..< count { + let model = SMDiagramViewModel() + model.originalProportion = dataSource?.diagramView?(self, proportionForSegmentAtIndex: i) ?? originalProportion + + totalProportion += model.originalProportion + + assert(roundf(Float(totalProportion*100))/100 <= 1.0 && totalProportion >= 0.0, "SMDiagramView. Sum of proportions = \(totalProportion) must be less or equal 1.0 or equal 0.0") + + model.calculatedProportion = model.originalProportion + + result.append(model) + } + + models = result + + updateProportions() + var tempStartAngle = startAngle + + for i in 0 ..< count + { + let model = models[i] + + model.startAngle = tempStartAngle + model.angleStep = (endAngle - startAngle)*model.calculatedProportion + + model.color = dataSource?.diagramView?(self, colorForSegmentAtIndex: i, angle: model.angle) ?? colorOfSegments + if let view = dataSource?.diagramView?(self, viewForSegmentAtIndex: i, colorOfSegment: model.color, angle: model.angle) + { + self.addSubview(view) + model.view = view + } + model.radiusOfSegment = dataSource?.diagramView?(self, radiusForSegmentAtIndex: i, proportion: model.calculatedProportion, angle: model.angle) ?? radiusOfSegments + model.radiusOfView = dataSource?.diagramView?(self, radiusForView: model.view, atIndex: i, radiusOfSegment: model.radiusOfSegment, angle: model.angle) ?? radiusOfViews + model.viewOffset = dataSource?.diagramView?(self, offsetForView: model.view, atIndex: i, angle: model.angle) ?? viewsOffset + model.lineWidth = dataSource?.diagramView?(self, lineWidthForSegmentAtIndex: i, angle: model.angle) ?? arcWidth + + model.separatorWidh = separatorWidh + model.diagramViewMode = diagramViewMode + tempStartAngle = model.endAngle + } + } else + { + showEmptyView() + } + } + + layoutIfNeeded() + setNeedsDisplay() + } + +} + +fileprivate class SMDiagramViewModel +{ + var view: UIView? + var color: UIColor? + var angleStep: CGFloat = 0.0 + var calculatedProportion: CGFloat = 0.0 + var originalProportion: CGFloat = 0.0 + var startAngle: CGFloat = 0.0 + private var _radiusOfSegment: CGFloat = 0.0 + private var _lineWidth: CGFloat = 0.0 + var radiusOfView: CGFloat = 0.0 + var viewOffset: CGPoint = .zero + var separatorWidh: CGFloat = 0.0 + var diagramViewMode: SMDiagramViewMode = .arc + + var radiusOfSegment: CGFloat + { + set + { + _radiusOfSegment = newValue + } + + get + { + switch diagramViewMode { + case .arc: + return _radiusOfSegment + case .segment: + return _radiusOfSegment/2.0 + } + } + } + + var lineWidth: CGFloat + { + set + { + _lineWidth = newValue + } + + get + { + switch diagramViewMode { + case .arc: + return _lineWidth + case .segment: + return _radiusOfSegment + } + } + } + + var endAngle: CGFloat + { + get + { + return startAngle + angleStep + } + } + + var angle: CGFloat + { + get + { + return endAngle - angleStep/2.0 + } + } + + var viewPosition: CGPoint + { + get + { + return CGPoint(x: xPosition, y: yPosition) + } + } + + private var xPosition: CGFloat + { + get + { + return CGFloat(cosf(Float(endAngle - angleStep/2.0))) * radiusOfView + viewOffset.x + } + } + + private var yPosition: CGFloat + { + get + { + return CGFloat(sinf(Float(endAngle - angleStep/2.0))) * radiusOfView + viewOffset.y + } + } + + var separatorStartAngle: CGFloat + { + get + { + return endAngle - separatorAngleStep/2.0 + } + } + + var separatorEndAngle: CGFloat + { + get + { + return endAngle + separatorAngleStep/2.0 + } + } + + var separatorAngleStep: CGFloat + { + get + { + return separatorWidh/radiusOfSegment + } + } + + var separatorLineWidth: CGFloat + { + get + { + return lineWidth + 2.0 / UIScreen.main.scale + } + } +} diff --git a/nightguard/StatsPeriodSelectorView.swift b/nightguard/StatsPeriodSelectorView.swift new file mode 100644 index 00000000..dbf5af71 --- /dev/null +++ b/nightguard/StatsPeriodSelectorView.swift @@ -0,0 +1,72 @@ +// +// StatsPeriodSelectorView.swift +// nightguard +// +// Created by Florian Preknya on 3/18/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +/** + This is a fake stats view, it is actualy a stats period changer; it was convenable to keep it as a stats view because of the unitar UI. + */ +class StatsPeriodSelectorView: BasicStatsControl { + + var onPeriodChangeRequest: ((BasicStats.Period) -> Void)? + + fileprivate var periods: [BasicStats.Period] = [ + .last24h, + .last8h, + .today, + .yesterday, + .todayAndYesterday + ] + + override func commonInit() { + super.commonInit() + + diagramView.dataSource = self + valueLabel?.font = UIFont.boldSystemFont(ofSize: DeviceSize().isSmall ? 11 : 13) + } + + override func modelWasSet() { + super.modelWasSet() + + updateTitleView(name: "Stats Period", value: model?.period.description) + } + + override func changePage() { + guard let period = self.model?.period else { return } + let nextPeriodIndex = ((periods.firstIndex(of: period) ?? -1) + 1) % periods.count + onPeriodChangeRequest?(periods[nextPeriodIndex]) + } + + override func updateValueLabel(_ value: String?, asDetail: Bool = false) { + super.updateValueLabel(value, asDetail: asDetail) + + // override value label font + valueLabel?.font = UIFont.boldSystemFont(ofSize: DeviceSize().isSmall ? 11 : 13) + } +} + +extension StatsPeriodSelectorView: SMDiagramViewDataSource { + + @objc func numberOfSegmentsIn(diagramView: SMDiagramView) -> Int { + return 1 + } + + // func diagramView(_ diagramView: SMDiagramView, colorForSegmentAtIndex index: NSInteger, angle: CGFloat) -> UIColor? { + // + // return UIColor.white.withAlphaComponent(0.025) + // } + + func diagramView(_ diagramView: SMDiagramView, radiusForSegmentAtIndex index: NSInteger, proportion: CGFloat, angle: CGFloat) -> CGFloat { + return (diagramView.frame.size.height - 2/*diagramView.arcWidth*/) / 2 + } + + func diagramView(_ diagramView: SMDiagramView, lineWidthForSegmentAtIndex index: NSInteger, angle: CGFloat) -> CGFloat { + //not called for SMDiagramViewModeSegment + return 2.0 + } +} diff --git a/nightguard/TouchReportingView.swift b/nightguard/TouchReportingView.swift new file mode 100644 index 00000000..95b28674 --- /dev/null +++ b/nightguard/TouchReportingView.swift @@ -0,0 +1,62 @@ +// +// TouchReportingView.swift +// nightguard +// +// Created by Florian Preknya on 3/21/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +/// UIButton or UITableViewCell like touch & tap detection UIControl, useful for giving a button behavior to any view (highlighting on touch, execute action on tap). +class TouchReportingView: UIControl { + + /// closure called when touch started (isHighlighted == true) + var onTouchStarted: (() -> Void)? + + /// closure called when touch ended (isHighlighted == false) + var onTouchEnded: (() -> Void)? + + /// closure called when a tap is detected + var onTouchUpInside: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + func commonInit() { + addTarget(self, action: #selector(touchedUp), for: .touchUpInside) + addTarget(self, action: #selector(touchedDown), for: .touchDown) + addTarget(self, action: #selector(exited), for: .touchCancel) + addTarget(self, action: #selector(exited), for: .touchDragExit) + addTarget(self, action: #selector(entered), for: .touchDragEnter) + } + + @objc func touchedDown() { + onTouchStarted?() + } + + @objc func entered() { + onTouchStarted?() + } + + @objc func exited() { + UIView.animate(withDuration: 0.4, animations: { [weak self] in + self?.onTouchEnded?() + }) + } + + @objc func touchedUp() { + UIView.animate(withDuration: 0.4, animations: { [weak self] in + self?.onTouchEnded?() + }) + + onTouchUpInside?() + } +} diff --git a/nightguard/UIView+Extensions.swift b/nightguard/UIView+Extensions.swift index 7f68a90f..54d22c1e 100644 --- a/nightguard/UIView+Extensions.swift +++ b/nightguard/UIView+Extensions.swift @@ -10,6 +10,7 @@ import UIKit extension UIView { func pin(to view: UIView) { + translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ leadingAnchor.constraint(equalTo: view.leadingAnchor), trailingAnchor.constraint(equalTo: view.trailingAnchor), diff --git a/nightguard/XibLoadedView.swift b/nightguard/XibLoadedView.swift new file mode 100644 index 00000000..9f3f4a70 --- /dev/null +++ b/nightguard/XibLoadedView.swift @@ -0,0 +1,62 @@ +// +// XibLoadedView.swift +// nightguard +// +// Created by Florian Preknya on 3/12/19. +// Copyright © 2019 private. All rights reserved. +// + +import UIKit + +class XibLoadedView: UIView { + + // the custom view from the XIB file + var view: UIView! + + // the XIB file name (by default, the class name - override this property in subclasses if needed) + var nibName: String { + return NSStringFromClass(type(of: self)).components(separatedBy: ".").last! + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + func commonInit() { + xibSetup() + } + + fileprivate func xibSetup() { + self.backgroundColor = .clear + view = loadViewFromNib() + + addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: self.leadingAnchor), + view.trailingAnchor.constraint(equalTo: self.trailingAnchor), + view.topAnchor.constraint(equalTo: self.topAnchor), + view.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + } + + fileprivate func loadViewFromNib() -> UIView { + let bundle = Bundle(for: type(of: self)) + let nibName = self.nibName + guard let _ = bundle.path(forResource: nibName, ofType: "nib") else { + return UIView() + } + + let nib = UINib(nibName: nibName, bundle: bundle) + + // Assumes UIView is top level and only object in CustomView.xib file + let view = nib.instantiate(withOwner: self, options: nil)[0] as! UIView + return view + } +}