diff --git a/xdrip/Constants/ConstantsGlucoseChart.swift b/xdrip/Constants/ConstantsGlucoseChart.swift index ab746d11e..5227ea039 100644 --- a/xdrip/Constants/ConstantsGlucoseChart.swift +++ b/xdrip/Constants/ConstantsGlucoseChart.swift @@ -68,5 +68,17 @@ enum ConstantsGlucoseChart { /// diameter of the circle for blood glucose readings static let glucoseCircleDiameter: CGFloat = 5 + + /// when user pans the chart, when ending the gesture, deceleration is done. At regular intervals the chart needs to be redrawn. This is the interval in seconds + static let decelerationTimerValueInSeconds = 0.02 + + /// deceleration rate to use when ending pan gesture on chart + static let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue + + /// maximum amount of elements in the glucoseChartPoints array, this will be limited for performance reasons + static let maximumElementsInGlucoseChartPointsArray:Int = 1000 + /// dateformat for minutesAgo label when user is panning the chart back in time. The label will show the timestamp of the latest shown value in the chart + static let dateFormatLatestChartPointWhenPanning = "E d MMM HH:mm" + } diff --git a/xdrip/Core Data/accessors/BgReadingsAccessor.swift b/xdrip/Core Data/accessors/BgReadingsAccessor.swift index 2eb64bd30..9b79049f2 100644 --- a/xdrip/Core Data/accessors/BgReadingsAccessor.swift +++ b/xdrip/Core Data/accessors/BgReadingsAccessor.swift @@ -38,12 +38,12 @@ class BgReadingsAccessor { /// - if ignoreCalculatedValue = true, then value of calculatedValue will be ignored /// - returns: an array with readings, can be empty array. /// Order by timestamp, descending meaning the reading at index 0 is the youngest - func getLatestBgReadings(limit:Int?, howOld maximumDays:Int?, forSensor sensor:Sensor?, ignoreRawData:Bool, ignoreCalculatedValue:Bool) -> [BgReading] { + func getLatestBgReadings(limit:Int?, howOld:Int?, forSensor sensor:Sensor?, ignoreRawData:Bool, ignoreCalculatedValue:Bool) -> [BgReading] { // if maximum age specified then create fromdate var fromDate:Date? - if let maximumDays = maximumDays, maximumDays >= 0 { - fromDate = Date(timeIntervalSinceNow: Double(-maximumDays * 60 * 60 * 24)) + if let howOld = howOld, howOld >= 0 { + fromDate = Date(timeIntervalSinceNow: Double(-howOld * 60 * 60 * 24)) } return getLatestBgReadings(limit: limit, fromDate: fromDate, forSensor: sensor, ignoreRawData: ignoreRawData, ignoreCalculatedValue: ignoreCalculatedValue) @@ -106,13 +106,15 @@ class BgReadingsAccessor { } /// gets readings on a managedObjectContact that is created with concurrencyType: .privateQueueConcurrencyType + /// - returns: + /// readings sorted by timestamp, ascending (ie first is oldest) /// - parameters: - /// - to : if specified, only return readings with timestamp smaller than fromDate - /// - from : if specified, only return readings with timestamp greater than fromDate - func getBgReadingOnPrivateManagedObjectContext(from: Date?, to: Date?) -> [BgReading] { + /// - to : if specified, only return readings with timestamp smaller than fromDate (not equal to) + /// - from : if specified, only return readings with timestamp greater than fromDate (not equal to) + func getBgReadingsOnPrivateManagedObjectContext(from: Date?, to: Date?) -> [BgReading] { let fetchRequest: NSFetchRequest = BgReading.fetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(BgReading.timeStamp), ascending: false)] + fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(BgReading.timeStamp), ascending: true)] // create predicate if let from = from, to == nil { @@ -148,6 +150,8 @@ class BgReadingsAccessor { /// - parameters: /// - limit: maximum amount of readings to fetch, if 0 then no limit /// - fromDate : if specified, only return readings with timestamp > fromDate + /// - returns: + /// List of readings, descending, ie first is youngest private func fetchBgReadings(limit:Int?, fromDate:Date?) -> [BgReading] { let fetchRequest: NSFetchRequest = BgReading.fetchRequest() fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(BgReading.timeStamp), ascending: false)] diff --git a/xdrip/Extensions/ChartPoint.swift b/xdrip/Extensions/ChartPoint.swift index bf2e475ed..9ee872c9e 100644 --- a/xdrip/Extensions/ChartPoint.swift +++ b/xdrip/Extensions/ChartPoint.swift @@ -3,6 +3,7 @@ import SwiftCharts extension ChartPoint { + // if bgReading.calculatedValue == 0 then return nil convenience init?(bgReading: BgReading, formatter: DateFormatter, unitIsMgDl: Bool) { if bgReading.calculatedValue > 0 { diff --git a/xdrip/Managers/Charts/GlucoseChartManager.swift b/xdrip/Managers/Charts/GlucoseChartManager.swift index f4de71b08..68dfbfe62 100644 --- a/xdrip/Managers/Charts/GlucoseChartManager.swift +++ b/xdrip/Managers/Charts/GlucoseChartManager.swift @@ -1,10 +1,8 @@ -// -// based on loopkit https://github.com/loopkit -// import Foundation import HealthKit import SwiftCharts import os.log +import UIKit public final class GlucoseChartManager { @@ -31,7 +29,7 @@ public final class GlucoseChartManager { } /// for logging - private var log = OSLog(subsystem: ConstantsLog.subSystem, category: ConstantsLog.categoryGlucoseChartManager) + private var oslog = OSLog(subsystem: ConstantsLog.subSystem, category: ConstantsLog.categoryGlucoseChartManager) private let chartSettings: ChartSettings = { var settings = ChartSettings() @@ -52,43 +50,11 @@ public final class GlucoseChartManager { private var chartGuideLinesLayerSettings: ChartGuideLinesLayerSettings /// The latest date on the X-axis - private var endDate: Date { - didSet { - if endDate != oldValue { - - xAxisValues = nil - - // current difference between end and startdate - let diffEndAndStartDate = oldValue.timeIntervalSince(startDate).hours - - // Set a new startdate, difference is equal to previous difference - startDate = endDate.addingTimeInterval(.hours(-diffEndAndStartDate)) - - } - } - } + private var endDate: Date /// The earliest date on the X-axis private var startDate: Date - /// A ChartAxisValue models a value along a particular chart axis. For example, two ChartAxisValues represent the two components of a ChartPoint. It has a backing Double scalar value, which provides a canonical form for all subclasses to be laid out along an axis. It also has one or more labels that are drawn in the chart. - /// - /// see https://github.com/i-schuetz/SwiftCharts/blob/ec538d027d6d4c64028d85f86d3d72fcda41c016/SwiftCharts/AxisValues/ChartAxisValue.swift#L12, is not meant to be instantiated - private var xAxisValues: [ChartAxisValue]? { - didSet { - - if let xAxisValues = xAxisValues, xAxisValues.count > 1 { - xAxisModel = ChartAxisModel(axisValues: xAxisValues, lineColor: ConstantsGlucoseChart.axisLineColor, labelSpaceReservationMode: .fixed(20)) - } else { - xAxisModel = nil - } - - glucoseChart = nil - } - } - - private var xAxisModel: ChartAxisModel? - /// the chart with glucose values private var glucoseChart: Chart? @@ -102,15 +68,56 @@ public final class GlucoseChartManager { }() /// timeformatter for horizontal axis label - private let axisLabelTimeFormatter: DateFormatter + private let axisLabelTimeFormatter: DateFormatter /// a BgReadingsAccessor private var bgReadingsAccessor: BgReadingsAccessor? - + + /// used when panning, difference in seconds between two points ? + /// + /// default value 1.0 which is probably not correct but it can't be initiated as long as innerFrameWidth is not initialized, to avoid having to work with optional, i assign it to 1.0 + private var diffInSecondsBetweenTwoPoints = 1.0 + + /// innerFrame width + /// + /// default value 300.0 which is probably not correct but it can't be initiated as long as glusoseChart is not initialized, to avoid having to work with optional, i assign it to 300.0 + private var innerFrameWidth: Double = 300.0 { + didSet { + diffInSecondsBetweenTwoPoints = endDate.timeIntervalSince(startDate)/Double(innerFrameWidth) + } + } + + /// used for getting bgreadings on a background thread + private let operationQueue: OperationQueue + + /// This timer is used when decelerating the chart after end of panning. We'll set a timer, each time the timer expires the chart will be shifted a bit + private var gestureTimer:RepeatingTimer? + + /// used when user stopped panning and deceleration is still ongoing. If set to true, then deceleration needs to be stopped + private var stopDeceleration = false + + /// the maximum value in glucoseChartPoints array between start and endPoint + /// + /// value calculated in loopThroughGlucoseChartPointsAndFindValues + private var maximumValueInGlucoseChartPoints = ConstantsGlucoseChart.absoluteMinimumChartValueInMgdl.mgdlToMmol(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + + /// - if glucoseChartPoints.count > 0, then this is the latest one that has timestamp less than endDate. + /// + /// value calculated in loopThroughGlucoseChartPointsAndFindValues + private(set) var lastChartPointEarlierThanEndDate: ChartPoint? + + /// is chart in panned state or not, meaning is it currently shifted back in time + private(set) var chartIsPannedBackward: Bool = false + // MARK: - intializer - init() { + /// - parameters: + /// - chartLongPressGestureRecognizer : defined here as parameter so that this class can handle the config of the recognizer + init(chartLongPressGestureRecognizer: UILongPressGestureRecognizer) { + // for tapping the chart, we're using UILongPressGestureRecognizer because UITapGestureRecognizer doesn't react on touch down. With UILongPressGestureRecognizer and minimumPressDuration set to 0, we get a trigger as soon as the chart is touched + chartLongPressGestureRecognizer.minimumPressDuration = 0 + chartLabelSettings = ChartLabelSettings( font: .systemFont(ofSize: 14), fontColor: ConstantsGlucoseChart.axisLabelColor @@ -121,52 +128,122 @@ public final class GlucoseChartManager { // initialize enddate endDate = Date() - // intialize startdate, which is enddate minus a few hours startDate = endDate.addingTimeInterval(.hours(-UserDefaults.standard.chartWidthInHours)) axisLabelTimeFormatter = DateFormatter() axisLabelTimeFormatter.dateFormat = UserDefaults.standard.chartTimeAxisLabelFormat + + // initialize operationQueue + operationQueue = OperationQueue() + + // operationQueue will be queue of blocks that gets readings and updates glucoseChartPoints, startDate and endDate. To avoid race condition, the operations should be one after the other + operationQueue.maxConcurrentOperationCount = 1 } // MARK: - public functions - /// updates the glucoseChartPoints array and calls completionHandler when finished, also chart is set to nil + /// updates the glucoseChartPoints array and calls completionHandler when finished - if called multiple times after each other (eg because user is panning or zooming fast) there might be calls skipped, ie completionhandler will not be called if skipped, only the last task in the operationqueue is used /// - parameters: - /// - completionHandler will be called when finished - public func updateGlucoseChartPoints(completionHandler: @escaping () -> ()) { - - guard let bgReadingsAccessor = bgReadingsAccessor else { - trace("in updateGlucoseChartPoints, bgReadingsAccessor, probably coreDataManager is not yet assigned", log: self.log, type: .info) - return - } - - let queue = OperationQueue() + /// - completionHandler : will be called when glucoseChartPoints array is ready to be used, in this completionhandler for instance chart should be upated + /// - endDate :endDate to apply + /// - startDate :startDate to apply, if nil then no change will be done in chardwidth, ie current difference between start and end will be reused + /// + /// update of glucoseChartPoints array will be done on background thread. The actual redrawing of the chartoutlet needs to be done on the main thread, so the caller adds a block of code in the completionHandler which will be executed in the main thread. + /// While updating glucoseChartPoints in background thread, the main thread may call again updateGlucoseChartPoints with a new endDate (because the user is panning or zooming). A new block will be added in the operation queue and processed later. + public func updateGlucoseChartPoints(endDate: Date, startDate: Date?, chartOutlet: BloodGlucoseChartView, completionHandler: (() -> ())?) { let operation = BlockOperation(block: { - // reset endDate - self.endDate = Date() + // if there's more than one operation waiting for execution, it makes no sense to execute this one, the next one has a newer endDate to use + guard self.operationQueue.operations.count <= 1 else { + return + } + + // startDateToUse is either parameter value or (if nil), endDate minutes current chartwidth + let startDateToUse = startDate != nil ? startDate! : Date(timeInterval: -self.endDate.timeIntervalSince(self.startDate), since: endDate) + + + guard let bgReadingsAccessor = self.bgReadingsAccessor else { + trace("in updateGlucoseChartPoints, bgReadingsAccessor, probably coreDataManager is not yet assigned", log: self.oslog, type: .info) + return + } - // get glucosePoints from coredata - let glucoseChartPoints = bgReadingsAccessor.getBgReadingOnPrivateManagedObjectContext(from: self.startDate, to: self.endDate).compactMap { + // we're going to check if we have already all chartpoints in the array self.glucoseChartPoints for the new start and date time. If not we're going to prepand a new array and/or append a new array + + // initialize new list of glucoseChartPoints to prepend + var newGlucoseChartPointsToPrepend = [ChartPoint]() + + // initialize new list of glucoseChartPoints to append + var newGlucoseChartPointsToAppend = [ChartPoint]() + + // do we reuse the existing list ? for instance if new startDate > date of currently stored last chartpoint, then we don't reuse the existing list, probably better to reinitialize from scratch to avoid ending up with too long lists + // and if there's more than a predefined amount of elements already in the array then we restart from scratch because (on an iPhone SE with iOS 13), the panning is getting slowed down when there's more than 1000 elements in the array + var reUseExistingChartPointList = self.glucoseChartPoints.count <= ConstantsGlucoseChart.maximumElementsInGlucoseChartPointsArray ? true:false + + if let lastGlucoseChartPoint = self.glucoseChartPoints.last, let lastGlucoseChartPointX = lastGlucoseChartPoint.x as? ChartAxisValueDate { + + // if reUseExistingChartPointListget = false, then we're actually forcing to use a complete new array, because the current array glucoseChartPoints is too big. If true, then we start from timestamp of the last chartpoint + let lastGlucoseTimeStamp = reUseExistingChartPointList ? lastGlucoseChartPointX.date : Date(timeIntervalSince1970: 0) - ChartPoint(bgReading: $0, formatter: self.chartPointDateFormatter, unitIsMgDl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + // first see if we need to append new chartpoints + if startDateToUse > lastGlucoseTimeStamp { + + // startDate is bigger than the the date of the last currently stored ChartPoint, let's reinitialize the glucosechartpoints + reUseExistingChartPointList = false + + // use newGlucoseChartPointsToAppend and assign it to new list of chartpoints startDate to endDate + newGlucoseChartPointsToAppend = self.getGlucoseChartPoints(startDate: startDateToUse, endDate: endDate, bgReadingsAccessor: bgReadingsAccessor) + + } else if endDate <= lastGlucoseTimeStamp { + // so starDate <= date of last known glucosechartpoint and enddate is also <= that date + // no need to append anything + } else { + + // append glucseChartpoints with date > x.date up to endDate + newGlucoseChartPointsToAppend = self.getGlucoseChartPoints(startDate: lastGlucoseTimeStamp, endDate: endDate, bgReadingsAccessor: bgReadingsAccessor) + } + // now see if we need to prepend + // if reUseExistingChartPointList = false, then it means startDate > date of last know glucosepoint, there's no need to prepend + if reUseExistingChartPointList { + + if let firstGlucoseChartPoint = self.glucoseChartPoints.first, let firstGlucoseChartPointX = firstGlucoseChartPoint.x as? ChartAxisValueDate, startDateToUse < firstGlucoseChartPointX.date { + + newGlucoseChartPointsToPrepend = self.getGlucoseChartPoints(startDate: startDateToUse, endDate: firstGlucoseChartPointX.date, bgReadingsAccessor: bgReadingsAccessor) + } + + } + + } else { + + // this should be a case where there's no glucoseChartPoints stored yet, we just create a new array to append + + // get glucosePoints from coredata + newGlucoseChartPointsToAppend = self.getGlucoseChartPoints(startDate: startDateToUse, endDate: endDate, bgReadingsAccessor: bgReadingsAccessor) } - - //let glucosePoints = BgReadingsAccessor.get + DispatchQueue.main.async { - self.glucoseChartPoints = glucoseChartPoints + // so we're in the main thread, now endDate and startDate and glucoseChartPoints can be safely assigned to value that was passed in the call to updateGlucoseChartPoints + self.endDate = endDate + self.startDate = startDateToUse + self.glucoseChartPoints = newGlucoseChartPointsToPrepend + (reUseExistingChartPointList ? self.glucoseChartPoints : [ChartPoint]()) + newGlucoseChartPointsToAppend - completionHandler() + // update the chart outlet + chartOutlet.reloadChart() + + // call completionhandler on main thread + if let completionHandler = completionHandler { + completionHandler() + } + } }) - queue.addOperation { + operationQueue.addOperation { operation.start() } @@ -174,9 +251,8 @@ public final class GlucoseChartManager { public func didReceiveMemoryWarning() { - trace("in didReceiveMemoryWarning, Purging chart data in response to memory warning", log: self.log, type: .error) + trace("in didReceiveMemoryWarning, Purging chart data in response to memory warning", log: self.oslog, type: .error) - xAxisValues = nil glucoseChartPoints = [] } @@ -185,7 +261,7 @@ public final class GlucoseChartManager { if let chart = glucoseChart, chart.frame != frame { - trace("Glucose chart frame changed to %{public}@", log: self.log, type: .info, String(describing: frame)) + trace("Glucose chart frame changed to %{public}@", log: self.oslog, type: .info, String(describing: frame)) self.glucoseChart = nil } @@ -196,40 +272,215 @@ public final class GlucoseChartManager { return glucoseChart } + + /// handles either UIPanGestureRecognizer or UILongPressGestureRecognizer. UILongPressGestureRecognizer is there to detect taps + /// - parameters: + /// - completionhandler : any block that caller wants to see executed when chart has been updated + /// - chartOutlet : needed to trigger updated of chart + public func handleUIGestureRecognizer(recognizer: UIGestureRecognizer, chartOutlet: BloodGlucoseChartView, completionHandler: (() -> ())?) { + + if let uiPanGestureRecognizer = recognizer as? UIPanGestureRecognizer { + + handleUiPanGestureRecognizer(uiPanGestureRecognizer: uiPanGestureRecognizer, chartOutlet: chartOutlet, completionHandler: completionHandler) + + } else if let uiLongPressGestureRecognizer = recognizer as? UILongPressGestureRecognizer { + + handleUiLongPressGestureRecognizer(uiLongPressGestureRecognizer: uiLongPressGestureRecognizer, chartOutlet: chartOutlet) + + } + + } + + // MARK: - private functions + + private func stopDeceleration() { + + // user touches the chart, in case we're handling a decelerating gesture, stop it + // call to suspend doesn't really seem to stop the deceleration, that's why also setting to nil and using stopDeceleration + gestureTimer?.suspend() + gestureTimer = nil + stopDeceleration = true + + } + + private func handleUiLongPressGestureRecognizer(uiLongPressGestureRecognizer: UILongPressGestureRecognizer, chartOutlet: BloodGlucoseChartView) { + + if uiLongPressGestureRecognizer.state == .began { + + stopDeceleration() + + } + + } + + private func handleUiPanGestureRecognizer(uiPanGestureRecognizer: UIPanGestureRecognizer, chartOutlet: BloodGlucoseChartView, completionHandler: (() -> ())?) { - /// Runs any necessary steps before rendering charts - public func prerender() { + if uiPanGestureRecognizer.state == .began { - if xAxisValues == nil { - generateXAxisValues() + // user touches the chart, possibily chart is still decelerating from a previous pan. Needs to be stopped + stopDeceleration() + + } + + let translationX = uiPanGestureRecognizer.translation(in: uiPanGestureRecognizer.view).x + + // if translationX negative and if not chartIsPannedBackward, then stop processing, we're not going back to the future + if !chartIsPannedBackward && translationX < 0 { + uiPanGestureRecognizer.setTranslation(CGPoint.zero, in: chartOutlet) + + if let completionHandler = completionHandler { + completionHandler() + } + + return + } + + // user either started panning backward or continues panning (back or forward). Assume chart is currently in backward panned state, which is probably true + chartIsPannedBackward = true + + if uiPanGestureRecognizer.state == .ended { + + // user has lifted finger. Deceleration needs to be done. + decelerate(translationX: translationX, velocityX: uiPanGestureRecognizer.velocity(in: uiPanGestureRecognizer.view).x, chartOutlet: chartOutlet, completionHandler: { + + + uiPanGestureRecognizer.setTranslation(CGPoint.zero, in: chartOutlet) + + // call the completion handler that was created by the original caller, in this case RootViewController created this code block + if let completionHandler = completionHandler { + completionHandler() + } + + }) + + } else { + + // ongoing panning + + // this will update the chart and set new start and enddate, for specific translation + setNewStartAndEndDate(translationX: translationX, chartOutlet: chartOutlet, completionHandler: { + + uiPanGestureRecognizer.setTranslation(CGPoint.zero, in: chartOutlet) + + // call the completion handler that was created by the original caller, in this case RootViewController created this code block + if let completionHandler = completionHandler { + completionHandler() + } + + }) + } } - // MARK: - private functions + /// - will call setNewStartAndEndDate with a new translationX value, every x milliseconds, x being 20 milliseconds by default as defined in the constants. + /// - Every time the new values are set, the completion handler will be called + /// - Every time the new values are set, chartOutlet will be updated + private func decelerate(translationX: CGFloat, velocityX: CGFloat, chartOutlet: BloodGlucoseChartView, completionHandler: @escaping () -> ()) { + + //The default deceleration rate is λ = 0.998, meaning that the scroll view loses 0.2% of its velocity per millisecond. + //The distance traveled is the area under the curve in a velocity-time-graph, thus the distance traveled until the content comes to rest is the integral of the velocity from zero to infinity. + // current deceleration = v*λ^t, t in milliseconds + // distanceTravelled = integral of current deceleration from 0 to actual time = λ^t/ln(λ) - λ^0/ln(λ) + // this is multiplied with 0.001, I don't know why but the result matches the formula that is advised by Apple to calculate target x, target x would be translationX + (velocityX / 1000.0) * decelerationRate / (1.0 - decelerationRate) + + /// this is the integral calculated for time 0 + let constant = Double(velocityX) * pow(Double(ConstantsGlucoseChart.decelerationRate), 0.0) / log(Double(ConstantsGlucoseChart.decelerationRate)) + + /// the start time, actual elapsed time will always be calculated agains this value + let initialStartOfDecelerationTimeStampInMilliseconds = Date().toMillisecondsAsDouble() + + /// initial distance travelled is nul, this will be increased each time + var distanceTravelled: CGFloat = 0.0 + + // set stopDeceleration to false initially + stopDeceleration = false + + // at regulat intervals new distance to travel the chart will be calculated and setNewStartAndEndDate will be called + gestureTimer = RepeatingTimer(timeInterval: TimeInterval(ConstantsGlucoseChart.decelerationTimerValueInSeconds), eventHandler: { + + // if stopDeceleration is set, then return + if self.stopDeceleration { + return + } + + // what is the elapsed time since the user ended the panning + let timeSinceStart = Date().toMillisecondsAsDouble() - initialStartOfDecelerationTimeStampInMilliseconds + + // calculate additional distance to travel the chart + let additionalDistanceToTravel = CGFloat(round(0.001*( + + Double(velocityX) * pow(Double(ConstantsGlucoseChart.decelerationRate), timeSinceStart) / log(Double(ConstantsGlucoseChart.decelerationRate)) + + - constant))) - distanceTravelled + + // if less than 2 pixels then stop the gestureTimer + if abs(additionalDistanceToTravel) < 2 { + self.stopDeceleration() + } + + self.setNewStartAndEndDate(translationX: translationX + additionalDistanceToTravel, chartOutlet: chartOutlet, completionHandler: completionHandler) + + // increase distance already travelled + distanceTravelled += additionalDistanceToTravel + + }) + + // start the timer + gestureTimer?.resume() + + } + + /// - calculates new startDate and endDate + /// - updates glucseChartPoints array for given translation + /// - uptdate chartOutlet + /// - calls block in completion handler. + private func setNewStartAndEndDate(translationX: CGFloat, chartOutlet: BloodGlucoseChartView, completionHandler: @escaping () -> ()) { + + // calculate new start and enddate, based on how much the user's been panning + var newEndDate = endDate.addingTimeInterval(-diffInSecondsBetweenTwoPoints * Double(translationX)) + + // maximum value should be current date + if newEndDate > Date() { + + newEndDate = Date() + + // this is also the time to set chartIsPannedBackward to false, user is panning back to the future, he can not pan further than the endDate so that chart will not be any panned state anymore + chartIsPannedBackward = false + + // stop the deceleration + stopDeceleration() + + } + + // newStartDate = enddate minus current difference between endDate and startDate + let newStartDate = Date(timeInterval: -self.endDate.timeIntervalSince(self.startDate), since: newEndDate) + + updateGlucoseChartPoints(endDate: newEndDate, startDate: newStartDate, chartOutlet: chartOutlet, completionHandler: completionHandler) + + } private func generateGlucoseChartWithFrame(_ frame: CGRect) -> Chart? { - guard let xAxisModel = xAxisModel, let xAxisValues = xAxisValues else {return nil} + // first calculate necessary values by looping through all chart points + loopThroughGlucoseChartPointsAndFindValues() + + let xAxisValues = generateXAxisValues() + guard xAxisValues.count > 1 else {return nil} + + let xAxisModel = ChartAxisModel(axisValues: xAxisValues, lineColor: ConstantsGlucoseChart.axisLineColor, labelSpaceReservationMode: .fixed(20)) + // just to save typing let unitIsMgDl = UserDefaults.standard.bloodGlucoseUnitIsMgDl - // create yAxisValues, start with 38 mgdl, this is to make sure we show a bit lower than the real lowest value which is isually 40 mgdl, make the label hidden + // create yAxisValues, start with 38 mgdl, this is to make sure we show a bit lower than the real lowest value which is usually 40 mgdl, make the label hidden let firstYAxisValue = ChartAxisValueDouble((ConstantsGlucoseChart.absoluteMinimumChartValueInMgdl).mgdlToMmol(mgdl: unitIsMgDl), labelSettings: chartLabelSettings) firstYAxisValue.hidden = true // create now the yAxisValues and add the first var yAxisValues = [firstYAxisValue as ChartAxisValue] - // determine the maximum value in the glucosechartPoint - // start with maximum value defined in constants - var maximumValueInGlucoseChartPoints = ConstantsGlucoseChart.absoluteMinimumChartValueInMgdl.mgdlToMmol(mgdl: unitIsMgDl) - // now iterate through glucosechartpoints to determine the maximum - for glucoseChartPoint in glucoseChartPoints { - maximumValueInGlucoseChartPoints = max(maximumValueInGlucoseChartPoints, glucoseChartPoint.y.scalar) - } - // add first series if unitIsMgDl { yAxisValues += ConstantsGlucoseChart.initialGlucoseValueRangeInMgDl.map { ChartAxisValueDouble($0, labelSettings: chartLabelSettings)} @@ -264,6 +515,8 @@ public final class GlucoseChartManager { let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame) + // now that we know innerFrame we can set innerFrameWidth + innerFrameWidth = Double(innerFrame.width) // Grid lines let gridLayer = ChartGuideLinesForValuesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, settings: chartGuideLinesLayerSettings, axisValuesX: Array(xAxisValues.dropFirst().dropLast()), axisValuesY: yAxisValues) @@ -285,11 +538,11 @@ public final class GlucoseChartManager { ) } - private func generateXAxisValues() { + private func generateXAxisValues() -> [ChartAxisValue] { // in the comments, assume it is now 13:26 and width is 6 hours, that means startDate = 07:26, endDate = 13:26 - /// how many full hours between startdate and enddate - result would be 6 - maybe we just need to use the userdefaults setting ? + /// how many full hours between startdate and enddate let amountOfFullHours = Int(ceil(endDate.timeIntervalSince(startDate).hours)) /// create array that goes from 1 to number of full hours, as helper to map to array of ChartAxisValueDate - array will go from 1 to 6 @@ -309,7 +562,53 @@ public final class GlucoseChartManager { xAxisValues.first?.hidden = true xAxisValues.last?.hidden = true - self.xAxisValues = xAxisValues + return xAxisValues + + } + + /// gets array of chartpoints that have a calculatedValue > 0 and date > startDate (not equal to) and < endDate (not equal to), from coreData + /// - returns: + /// - chartpoints for readings that have calculatedvalue > 0, order ascending, ie first element is the oldest + private func getGlucoseChartPoints(startDate: Date, endDate: Date, bgReadingsAccessor: BgReadingsAccessor) -> [ChartPoint] { + + return bgReadingsAccessor.getBgReadingsOnPrivateManagedObjectContext(from: startDate, to: endDate).compactMap { + + ChartPoint(bgReading: $0, formatter: self.chartPointDateFormatter, unitIsMgDl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + + } + } + + /// function to be called when glucoseChartPoints array is updated, as first function in generateGlucoseChartWithFrame. Will loop through glucoseChartPoints and find : + /// - the maximum bg value of the chartPoints between start and end date + /// - the timeStamp of the chartPoint with the highest timestamp that is still lower than the endDate, in the list of glucoseChartPoints + private func loopThroughGlucoseChartPointsAndFindValues() { + + maximumValueInGlucoseChartPoints = ConstantsGlucoseChart.absoluteMinimumChartValueInMgdl.mgdlToMmol(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + + lastChartPointEarlierThanEndDate = nil + + for glucoseChartPoint in glucoseChartPoints { + + maximumValueInGlucoseChartPoints = max(maximumValueInGlucoseChartPoints, glucoseChartPoint.y.scalar) + + if let lastChartPointEarlierThanEndDate = lastChartPointEarlierThanEndDate { + + if + (glucoseChartPoint.x as! ChartAxisValueDate).date <= endDate + && + (lastChartPointEarlierThanEndDate.x as! ChartAxisValueDate).date < (glucoseChartPoint.x as! ChartAxisValueDate).date { + + self.lastChartPointEarlierThanEndDate = glucoseChartPoint + + } + + } else { + + lastChartPointEarlierThanEndDate = glucoseChartPoint + + } + + } } diff --git a/xdrip/Managers/NightScout/NightScoutUploadManager.swift b/xdrip/Managers/NightScout/NightScoutUploadManager.swift index 601d407fb..953f2188a 100644 --- a/xdrip/Managers/NightScout/NightScoutUploadManager.swift +++ b/xdrip/Managers/NightScout/NightScoutUploadManager.swift @@ -19,7 +19,7 @@ public class NightScoutUploadManager:NSObject { private let nightScoutAuthTestPath = "/api/v1/experiments/test" /// for logging - private var log = OSLog(subsystem: ConstantsLog.subSystem, category: ConstantsLog.categoryNightScoutUploadManager) + private var oslog = OSLog(subsystem: ConstantsLog.subSystem, category: ConstantsLog.categoryNightScoutUploadManager) /// BgReadingsAccessor instance private let bgReadingsAccessor:BgReadingsAccessor @@ -82,7 +82,7 @@ public class NightScoutUploadManager:NSObject { if success { self.upload() } else { - trace("in observeValue, NightScout credential check failed", log: self.log, type: .info) + trace("in observeValue, NightScout credential check failed", log: self.oslog, type: .info) } } }) @@ -104,7 +104,7 @@ public class NightScoutUploadManager:NSObject { if success { self.upload() } else { - trace("in observeValue, NightScout credential check failed", log: self.log, type: .info) + trace("in observeValue, NightScout credential check failed", log: self.oslog, type: .info) } } }) @@ -123,7 +123,7 @@ public class NightScoutUploadManager:NSObject { private func uploadBgReadingsToNightScout(siteURL:String, apiKey:String) { - trace("in uploadBgReadingsToNightScout", log: self.log, type: .info) + trace("in uploadBgReadingsToNightScout", log: self.oslog, type: .info) // get readings to upload, limit to x days, x = ConstantsNightScout.maxDaysToUpload var timeStamp = Date(timeIntervalSinceNow: TimeInterval(-ConstantsNightScout.maxDaysToUpload*24*60*60)) @@ -137,7 +137,7 @@ public class NightScoutUploadManager:NSObject { let bgReadingsToUpload = bgReadingsAccessor.getLatestBgReadings(limit: nil, fromDate: timeStamp, forSensor: nil, ignoreRawData: true, ignoreCalculatedValue: false) if bgReadingsToUpload.count > 0 { - trace(" number of readings to upload : %{public}@", log: self.log, type: .info, bgReadingsToUpload.count.description) + trace(" number of readings to upload : %{public}@", log: self.oslog, type: .info, bgReadingsToUpload.count.description) // map readings to dictionaryRepresentation let bgReadingsDictionaryRepresentation = bgReadingsToUpload.map({$0.dictionaryRepresentationForNightScoutUpload}) @@ -164,7 +164,7 @@ public class NightScoutUploadManager:NSObject { // Create upload Task let dataTask = sharedSession.uploadTask(with: request, from: sendData, completionHandler: { (data, response, error) -> Void in - trace("in uploadTask completionHandler", log: self.log, type: .info) + trace("in uploadTask completionHandler", log: self.oslog, type: .info) // if ends without success then log the data var success = false @@ -172,7 +172,7 @@ public class NightScoutUploadManager:NSObject { if !success { if let data = data { if let dataAsString = String(bytes: data, encoding: .utf8) { - trace(" data = %{public}@", log: self.log, type: .error, dataAsString) + trace(" data = %{public}@", log: self.oslog, type: .error, dataAsString) } } } @@ -180,18 +180,18 @@ public class NightScoutUploadManager:NSObject { // error cases if let error = error { - trace(" failed to upload, error = %{public}@", log: self.log, type: .error, error.localizedDescription) + trace(" failed to upload, error = %{public}@", log: self.oslog, type: .error, error.localizedDescription) return } // check that response is HTTPURLResponse and error code between 200 and 299 if let response = response as? HTTPURLResponse { guard (200...299).contains(response.statusCode) else { - trace(" failed to upload, statuscode = %{public}@", log: self.log, type: .error, response.statusCode.description) + trace(" failed to upload, statuscode = %{public}@", log: self.oslog, type: .error, response.statusCode.description) return } } else { - trace(" response is not HTTPURLResponse", log: self.log, type: .error) + trace(" response is not HTTPURLResponse", log: self.oslog, type: .error) } // successful cases, @@ -199,7 +199,7 @@ public class NightScoutUploadManager:NSObject { // change timeStampLatestNightScoutUploadedBgReading if let lastReading = bgReadingsToUpload.first { - trace(" upload succeeded, setting timeStampLatestNightScoutUploadedBgReading to %{public}@", log: self.log, type: .info, lastReading.timeStamp.description(with: .current)) + trace(" upload succeeded, setting timeStampLatestNightScoutUploadedBgReading to %{public}@", log: self.oslog, type: .info, lastReading.timeStamp.description(with: .current)) UserDefaults.standard.timeStampLatestNightScoutUploadedBgReading = lastReading.timeStamp } @@ -207,11 +207,11 @@ public class NightScoutUploadManager:NSObject { dataTask.resume() } } catch let error { - trace(" %{public}@", log: self.log, type: .info, error.localizedDescription) + trace(" %{public}@", log: self.oslog, type: .info, error.localizedDescription) } } else { - trace(" no readings to upload", log: self.log, type: .info) + trace(" no readings to upload", log: self.oslog, type: .info) } } diff --git a/xdrip/Storyboards/Base.lproj/Main.storyboard b/xdrip/Storyboards/Base.lproj/Main.storyboard index 855c25ccd..65ae61c7d 100644 --- a/xdrip/Storyboards/Base.lproj/Main.storyboard +++ b/xdrip/Storyboards/Base.lproj/Main.storyboard @@ -65,6 +65,11 @@ + + + + + @@ -95,7 +100,9 @@ + + @@ -106,7 +113,17 @@ - + + + + + + + + + + + diff --git a/xdrip/Utilities/Trace.swift b/xdrip/Utilities/Trace.swift index fba58b905..95a9a492e 100644 --- a/xdrip/Utilities/Trace.swift +++ b/xdrip/Utilities/Trace.swift @@ -6,7 +6,6 @@ fileprivate var log:OSLog = { return log }() - /// will only be used during development func debuglogging(_ logtext:String) { os_log("%{public}@", log: log, type: .debug, logtext) diff --git a/xdrip/View Controllers/Root View Controller/RootViewController.swift b/xdrip/View Controllers/Root View Controller/RootViewController.swift index eeb983b0e..5dbfc5a78 100644 --- a/xdrip/View Controllers/Root View Controller/RootViewController.swift +++ b/xdrip/View Controllers/Root View Controller/RootViewController.swift @@ -58,20 +58,71 @@ final class RootViewController: UIViewController { @IBOutlet weak var chartOutlet: BloodGlucoseChartView! /// user long pressed the value label - @IBAction func valueLabelLongPressedAction(_ sender: UILongPressGestureRecognizer) { + @IBAction func valueLabelLongPressGestureRecognizerAction(_ sender: UILongPressGestureRecognizer) { valueLabelLongPressed(sender) } + @IBAction func chartPanGestureRecognizerAction(_ sender: UIPanGestureRecognizer) { + + glucoseChartManager.handleUIGestureRecognizer(recognizer: sender, chartOutlet: chartOutlet, completionHandler: { + + // user has been panning, if chart is panned backward, then need to set valueLabel to value of latest chartPoint shown in the chart, and minutesAgo text to timeStamp of latestChartPoint + if self.glucoseChartManager.chartIsPannedBackward { + + if let lastChartPointEarlierThanEndDate = self.glucoseChartManager.lastChartPointEarlierThanEndDate, let chartAxisValueDate = lastChartPointEarlierThanEndDate.x as? ChartAxisValueDate { + + // valuueLabel text should not be strikethrough (might still be strikethrough in case latest reading is older than 10 minutes + self.valueLabelOutlet.attributedText = nil + // set value to value of latest chartPoint + self.valueLabelOutlet.text = lastChartPointEarlierThanEndDate.y.scalar.mgdlToMmolAndToString(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + + // set timestamp to timestamp of latest chartPoint, in red so user can notice this is an old value + self.minutesLabelOutlet.text = self.dateTimeFormatterForMinutesLabelWhenPanning.string(from: chartAxisValueDate.date) + self.minutesLabelOutlet.textColor = UIColor.red + + // don't show anything in diff outlet + self.diffLabelOutlet.text = "" + + } else { + + // this should normally not happen because lastChartPointEarlierThanEndDate should normally always be set + self.updateLabelsAndChart() + + } + + } else { + + // chart is not panned, update labels is necessary + self.updateLabelsAndChart() + + } + + }) + + } + + @IBOutlet var chartPanGestureRecognizerOutlet: UIPanGestureRecognizer! + + @IBAction func chartLongPressGestureRecognizerAction(_ sender: UILongPressGestureRecognizer) { + + // this one needs trigger in case user has panned, chart is decelerating, user clicks to stop the decleration, call to handleUIGestureRecognizer will stop the deceleration + // there's no completionhandler needed because the call in chartPanGestureRecognizerAction to handleUIGestureRecognizer already includes a completionhandler + glucoseChartManager.handleUIGestureRecognizer(recognizer: sender, chartOutlet: chartOutlet, completionHandler: nil) + + } + + @IBOutlet var chartLongPressGestureRecognizerOutlet: UILongPressGestureRecognizer! + // MARK: - Constants for ApplicationManager usage - /// constant for key in ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground - create updatelabelstimer - private let applicationManagerKeyCreateUpdateLabelsTimer = "RootViewController-CreateUpdateLabelsTimer" + /// constant for key in ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground - create updateLabelsAndChartTimer + private let applicationManagerKeyCreateupdateLabelsAndChartTimer = "RootViewController-CreateupdateLabelsAndChartTimer" - /// constant for key in ApplicationManager.shared.addClosureToRunWhenAppDidEnterBackground - invalidate updatelabelstimer - private let applicationManagerKeyInvalidateUpdateLabelsTimer = "RootViewController-InvalidateUpdateLabelsTimer" + /// constant for key in ApplicationManager.shared.addClosureToRunWhenAppDidEnterBackground - invalidate updateLabelsAndChartTimer + private let applicationManagerKeyInvalidateupdateLabelsAndChartTimer = "RootViewController-InvalidateupdateLabelsAndChartTimer" /// constant for key in ApplicationManager.shared.addClosureToRunWhenAppDidEnterBackground - updateLabels - private let applicationManagerKeyUpdateLabels = "RootViewController-UpdateLabels" + private let applicationManagerKeyUpdateLabelsAndChart = "RootViewController-UpdateLabelsAndChart" /// constant for key in ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground - initiate pairing private let applicationManagerKeyInitiatePairing = "RootViewController-InitiatePairing" @@ -138,24 +189,43 @@ final class RootViewController: UIViewController { /// timestamp of last notification for pairing private var timeStampLastNotificationForPairing:Date? - /// manages m5Stack that this app knows + /// manages m5Stacks that this app knows private var m5StackManager: M5StackManager? /// manage glucose chart - private var glucoseChartManager: GlucoseChartManager? + private var glucoseChartManager: GlucoseChartManager! + + /// dateformatter for minutesLabelOutlet, when user is panning the chart + private let dateTimeFormatterForMinutesLabelWhenPanning: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = ConstantsGlucoseChart.dateFormatLatestChartPointWhenPanning + + return dateFormatter + }() // MARK: - View Life Cycle + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // never seen it triggered, copied that from Loop + glucoseChartManager.didReceiveMemoryWarning() + + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // viewWillAppear when user switches eg from Settings Tab to Home Tab - latest reading value needs to be shown on the view, and also update minutes ago etc. updateLabelsAndChart() + } override func viewDidLoad() { super.viewDidLoad() + // initialize glucoseChartManager + glucoseChartManager = GlucoseChartManager(chartLongPressGestureRecognizer: chartLongPressGestureRecognizerOutlet) + // Setup Core Data Manager - setting up coreDataManager happens asynchronously // completion handler is called when finished. This gives the app time to already continue setup which is independent of coredata, like initializing the views coreDataManager = CoreDataManager(modelName: ConstantsCoreData.modelName, completion: { @@ -168,16 +238,13 @@ final class RootViewController: UIViewController { // create transmitter based on UserDefaults self.initializeCGMTransmitter() - // glucoseChartManager still needs the refernce to coreDataManager - self.glucoseChartManager?.coreDataManager = self.coreDataManager - // and now call again updateGlucoseChart, as readings can be fetched now from coreData - self.updateGlucoseChart() + // glucoseChartManager still needs the reference to coreDataManager + self.glucoseChartManager.coreDataManager = self.coreDataManager + // and now call again updateChart, as readings can be fetched now from coreData + self.updateChartWithResetEndDate() }) - // initialize glucoseChartManager - glucoseChartManager = GlucoseChartManager() - // Setup View setupView() @@ -217,7 +284,7 @@ final class RootViewController: UIViewController { self.tabBarController?.delegate = self // setup the timer logic for updating the view regularly - setupUpdateLabelsTimer() + setupUpdateLabelsAndChartTimer() // if licenseinfo not yet accepted, show license info with only ok button if !UserDefaults.standard.licenseInfoAccepted { @@ -236,15 +303,15 @@ final class RootViewController: UIViewController { self.present(alert, animated: true, completion: nil) } - // whenever app comes from-back to freground, updateLabels needs to be called - ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground(key: applicationManagerKeyUpdateLabels, closure: {self.updateLabelsAndChart()}) + // whenever app comes from-back to foreground, updateLabelsAndChart needs to be called + ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground(key: applicationManagerKeyUpdateLabelsAndChart, closure: {self.updateLabelsAndChart()}) // setup AVAudioSession setupAVAudioSession() // initialize chartGenerator in chartOutlet self.chartOutlet.chartGenerator = { [weak self] (frame) in - return self?.glucoseChartManager?.glucoseChartWithFrame(frame)?.view + return self?.glucoseChartManager.glucoseChartWithFrame(frame)?.view } // user may have long pressed the value label, so the screen will not lock, when going back to background, set isIdleTimerDisabled back to false @@ -496,8 +563,10 @@ final class RootViewController: UIViewController { preSnoozeButtonOutlet.setTitle(Texts_HomeView.snoozeButton, for: .normal) transmitterButtonOutlet.setTitle(Texts_HomeView.transmitter, for: .normal) - // at this moment, coreDataManager is not yet initialized, we're just calling here prerender and reloadChart to show the chart with x and y axis and gridlines, but without readings. The readings will be loaded once coreDataManager is setup, after which updateGlucoseChart() will be called, which will initiate loading of readings from coredata - self.glucoseChartManager?.prerender() + chartLongPressGestureRecognizerOutlet.delegate = self + chartPanGestureRecognizerOutlet.delegate = self + + // at this moment, coreDataManager is not yet initialized, we're just calling here prerender and reloadChart to show the chart with x and y axis and gridlines, but without readings. The readings will be loaded once coreDataManager is setup, after which updateChart() will be called, which will initiate loading of readings from coredata self.chartOutlet.reloadChart() } @@ -513,12 +582,10 @@ final class RootViewController: UIViewController { } - private func updateGlucoseChart() { + /// will update the chart with endDate = currentDate + private func updateChartWithResetEndDate() { - glucoseChartManager?.updateGlucoseChartPoints { - self.glucoseChartManager?.prerender() - self.chartOutlet.reloadChart() - } + glucoseChartManager.updateGlucoseChartPoints(endDate: Date(), startDate: nil, chartOutlet: chartOutlet, completionHandler: nil) } @@ -541,34 +608,34 @@ final class RootViewController: UIViewController { /// launches timer that will do regular screen updates - and adds closure to ApplicationManager : when going to background, stop the timer, when coming to foreground, restart the timer /// /// should be called only once immediately after app start, ie in viewdidload - private func setupUpdateLabelsTimer() { + private func setupUpdateLabelsAndChartTimer() { // this is the actual timer - var updateLabelsTimer:Timer? + var updateLabelsAndChartTimer:Timer? // create closure to invalide the timer, if it exists - let invalidateUpdateLabelsTimer = { - if let updateLabelsTimer = updateLabelsTimer { - updateLabelsTimer.invalidate() + let invalidateUpdateLabelsAndChartTimer = { + if let updateLabelsAndChartTimer = updateLabelsAndChartTimer { + updateLabelsAndChartTimer.invalidate() } } // create closure that launches the timer to update the first view every x seconds, and returns the created timer - let createAndScheduleUpdateLabelsTimer:() -> Timer = { + let createAndScheduleUpdateLabelsAndChartTimer:() -> Timer = { // check if timer already exists, if so invalidate it - invalidateUpdateLabelsTimer() + invalidateUpdateLabelsAndChartTimer() // now recreate, schedule and return return Timer.scheduledTimer(timeInterval: ConstantsHomeView.updateHomeViewIntervalInSeconds, target: self, selector: #selector(self.updateLabelsAndChart), userInfo: nil, repeats: true) } - // call scheduleUpdateLabelsTimer function now - as the function setupUpdateLabelsTimer is called from viewdidload, it will be called immediately after app launch - updateLabelsTimer = createAndScheduleUpdateLabelsTimer() + // call scheduleUpdateLabelsAndChartTimer function now - as the function setupUpdateLabelsAndChartTimer is called from viewdidload, it will be called immediately after app launch + updateLabelsAndChartTimer = createAndScheduleUpdateLabelsAndChartTimer() - // updateLabelsTimer needs to be created when app comes back from background to foreground - ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground(key: applicationManagerKeyCreateUpdateLabelsTimer, closure: {updateLabelsTimer = createAndScheduleUpdateLabelsTimer()}) + // updateLabelsAndChartTimer needs to be created when app comes back from background to foreground + ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground(key: applicationManagerKeyCreateupdateLabelsAndChartTimer, closure: {updateLabelsAndChartTimer = createAndScheduleUpdateLabelsAndChartTimer()}) - // updateLabelsTimer needs to be invalidated when app goes to background - ApplicationManager.shared.addClosureToRunWhenAppDidEnterBackground(key: applicationManagerKeyInvalidateUpdateLabelsTimer, closure: {invalidateUpdateLabelsTimer()}) + // updateLabelsAndChartTimer needs to be invalidated when app goes to background + ApplicationManager.shared.addClosureToRunWhenAppDidEnterBackground(key: applicationManagerKeyInvalidateupdateLabelsAndChartTimer, closure: {invalidateUpdateLabelsAndChartTimer()}) } /// opens an alert, that requests user to enter a calibration value, and calibrates @@ -854,78 +921,80 @@ final class RootViewController: UIViewController { } } - /// updates the homescreen labels and chart + /// updates the labels and the chart, @objc private func updateLabelsAndChart() { - // check that bgReadingsAccessor exists, otherwise return - this happens if updateLabels is called from viewDidload at app launch + // check if chart is currently panned back in time, in that case we don't update the labels + if glucoseChartManager.chartIsPannedBackward { + return + } + + // check that bgReadingsAccessor exists, otherwise return - this happens if updateLabelsAndChart is called from viewDidload at app launch guard let bgReadingsAccessor = bgReadingsAccessor else {return} - // last reading and lateButOneReading variable definition - optional - var lastReading:BgReading? - var lastButOneReading:BgReading? + // set minutesLabelOutlet.textColor to black, might still be red due to panning back in time + self.minutesLabelOutlet.textColor = UIColor.black - // assign latestReading if it exists - let latestReadings = bgReadingsAccessor.getLatestBgReadings(limit: 2, howOld: 1, forSensor: nil, ignoreRawData: true, ignoreCalculatedValue: false) - if latestReadings.count > 0 { - lastReading = latestReadings[0] - } - if latestReadings.count > 1 { - lastButOneReading = latestReadings[1] + // get latest reading, doesn't matter if it's for an active sensor or not, but it needs to have calculatedValue > 0 / which means, if user would have started a new sensor, but didn't calibrate yet, and a reading is received, then there's not going to be a latestReading + let latestReadings = bgReadingsAccessor.getLatestBgReadings(limit: 2, howOld: nil, forSensor: nil, ignoreRawData: true, ignoreCalculatedValue: false) + + // if there's no readings, then give empty fields + guard latestReadings.count > 0 else { + valueLabelOutlet.text = "---" + minutesLabelOutlet.text = "" + diffLabelOutlet.text = "" + return } + + // assign last reading + let lastReading = latestReadings[0] + // assign last but one reading + let lastButOneReading = latestReadings.count > 1 ? latestReadings[1]:nil + + // start creating text for valueLabelOutlet, first the calculated value + var calculatedValueAsString = lastReading.unitizedString(unitIsMgDl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) - // get latest reading, doesn't matter if it's for an active sensor or not, but it needs to have calculatedValue > 0 / which means, if user would have started a new sensor, but didn't calibrate yet, and a reading is received, then there's no going to be a latestReading - if let lastReading = lastReading { + // if latestReading older dan 11 minutes, then it should be strikethrough + if lastReading.timeStamp < Date(timeIntervalSinceNow: -60 * 11) { - // start creating text for valueLabelOutlet, first the calculated value - var calculatedValueAsString = lastReading.unitizedString(unitIsMgDl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + let attributeString: NSMutableAttributedString = NSMutableAttributedString(string: calculatedValueAsString) + attributeString.addAttribute(.strikethroughStyle, value: 2, range: NSMakeRange(0, attributeString.length)) - // if latestReading older dan 11 minutes, then it should be strikethrough - if lastReading.timeStamp < Date(timeIntervalSinceNow: -60 * 11) { - - let attributeString: NSMutableAttributedString = NSMutableAttributedString(string: calculatedValueAsString) - attributeString.addAttribute(.strikethroughStyle, value: 2, range: NSMakeRange(0, attributeString.length)) - - valueLabelOutlet.attributedText = attributeString - - } else { - - if !lastReading.hideSlope { - calculatedValueAsString = calculatedValueAsString + " " + lastReading.slopeArrow() - } - - // no strikethrough needed, but attributedText may still be set to strikethrough from previous period during which there was no recent reading. Always set it to nil here, this removes the strikethrough attribute - valueLabelOutlet.attributedText = nil - - valueLabelOutlet.text = calculatedValueAsString - - } + valueLabelOutlet.attributedText = attributeString - // set color, depending on value lower than low mark or higher than high mark - if lastReading.calculatedValue <= UserDefaults.standard.lowMarkValueInUserChosenUnit.mmolToMgdl(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) { - valueLabelOutlet.textColor = UIColor.red - } else if lastReading.calculatedValue >= UserDefaults.standard.highMarkValueInUserChosenUnit.mmolToMgdl(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) { - valueLabelOutlet.textColor = "#a0b002".hexStringToUIColor() - } else { - valueLabelOutlet.textColor = UIColor.black - } + } else { - // get minutes ago and create text for minutes ago label - let minutesAgo = -Int(lastReading.timeStamp.timeIntervalSinceNow) / 60 - let minutesAgoText = minutesAgo.description + " " + (minutesAgo == 1 ? Texts_Common.minute:Texts_Common.minutes) + " " + Texts_HomeView.ago + if !lastReading.hideSlope { + calculatedValueAsString = calculatedValueAsString + " " + lastReading.slopeArrow() + } - minutesLabelOutlet.text = minutesAgoText + // no strikethrough needed, but attributedText may still be set to strikethrough from previous period during which there was no recent reading. Always set it to nil here, this removes the strikethrough attribute + valueLabelOutlet.attributedText = nil - // create delta text - diffLabelOutlet.text = lastReading.unitizedDeltaString(previousBgReading: lastButOneReading, showUnit: true, highGranularity: true, mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + valueLabelOutlet.text = calculatedValueAsString + } + + // set color, depending on value lower than low mark or higher than high mark + if lastReading.calculatedValue <= UserDefaults.standard.lowMarkValueInUserChosenUnit.mmolToMgdl(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) { + valueLabelOutlet.textColor = UIColor.red + } else if lastReading.calculatedValue >= UserDefaults.standard.highMarkValueInUserChosenUnit.mmolToMgdl(mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) { + valueLabelOutlet.textColor = "#a0b002".hexStringToUIColor() } else { - valueLabelOutlet.text = "---" - minutesLabelOutlet.text = "" - diffLabelOutlet.text = "" + valueLabelOutlet.textColor = UIColor.black } - // update chart - updateGlucoseChart() + // get minutes ago and create text for minutes ago label + let minutesAgo = -Int(lastReading.timeStamp.timeIntervalSinceNow) / 60 + let minutesAgoText = minutesAgo.description + " " + (minutesAgo == 1 ? Texts_Common.minute:Texts_Common.minutes) + " " + Texts_HomeView.ago + + minutesLabelOutlet.text = minutesAgoText + + // create delta text + diffLabelOutlet.text = lastReading.unitizedDeltaString(previousBgReading: lastButOneReading, showUnit: true, highGranularity: true, mgdl: UserDefaults.standard.bloodGlucoseUnitIsMgDl) + + // update the chart up to now + updateChartWithResetEndDate() } @@ -1326,7 +1395,7 @@ extension RootViewController:CGMTransmitterDelegate { let maxTimeUserCanOpenApp = Date(timeIntervalSinceNow: TimeInterval(ConstantsDexcomG5.maxTimeToAcceptPairingInSeconds - 1)) // we will not just count on it that the user will click the notification to open the app (assuming the app is in the background, if the app is in the foreground, then we come in another flow) - // whenever app comes from-back to foreground, updateLabels needs to be called + // whenever app comes from-back to foreground, updateLabelsAndChart needs to be called ApplicationManager.shared.addClosureToRunWhenAppWillEnterForeground(key: applicationManagerKeyInitiatePairing, closure: { // first of all reremove from application key manager @@ -1593,3 +1662,21 @@ extension RootViewController:NightScoutFollowerDelegate { } } } + +extension RootViewController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + + if gestureRecognizer.view != chartOutlet { + return false + } + + if gestureRecognizer.view != otherGestureRecognizer.view { + return false + } + + return true + + } + +} diff --git a/xdrip/Views/BloodGlucoseChartView.swift b/xdrip/Views/BloodGlucoseChartView.swift index 555b16a7a..b564cd2ba 100644 --- a/xdrip/Views/BloodGlucoseChartView.swift +++ b/xdrip/Views/BloodGlucoseChartView.swift @@ -4,7 +4,7 @@ import SwiftCharts // source used : https://github.com/LoopKit/Loop /// a SwiftChart to be added -class BloodGlucoseChartView: UIView { +public class BloodGlucoseChartView: UIView { // MARK: - public properties