diff --git a/Cartfile b/Cartfile index 439e66e555..ca66f13586 100644 --- a/Cartfile +++ b/Cartfile @@ -1,7 +1,7 @@ -github "LoopKit/LoopKit" ~> 3.0 -github "LoopKit/CGMBLEKit" ~> 3.2 +github "LoopKit/LoopKit" "dev" +github "LoopKit/CGMBLEKit" "dev" github "i-schuetz/SwiftCharts" == 0.6.5 -github "LoopKit/dexcom-share-client-swift" ~> 1.2 -github "LoopKit/G4ShareSpy" ~> 1.1 -github "ps2/rileylink_ios" ~> 3.0 +github "LoopKit/dexcom-share-client-swift" "dev" +github "LoopKit/G4ShareSpy" "dev" +github "ps2/rileylink_ios" "dev" github "LoopKit/Amplitude-iOS" "decreepify" diff --git a/Cartfile.resolved b/Cartfile.resolved index 1e048677ca..e4c3b119e5 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,8 +1,8 @@ github "LoopKit/Amplitude-iOS" "2137d5fd44bf630ed33e1e72d7af6d8f8612f270" -github "LoopKit/CGMBLEKit" "v3.2" -github "LoopKit/G4ShareSpy" "v1.1" -github "LoopKit/LoopKit" "v3.0" +github "LoopKit/CGMBLEKit" "edcf503ef8fe260b69ca98eae508e679dfd9ad69" +github "LoopKit/G4ShareSpy" "88c5a296e83ce618319ae365a7e6da76d9be126b" +github "LoopKit/LoopKit" "6940ab10d258b21e0a33aa5729b1111a8d332d77" github "LoopKit/MKRingProgressView" "f548a5c64832be2d37d7c91b5800e284887a2a0a" -github "LoopKit/dexcom-share-client-swift" "v1.2" +github "LoopKit/dexcom-share-client-swift" "c37d87fcf121c44721e87c9e6d2bea94e48728cc" github "i-schuetz/SwiftCharts" "0.6.5" -github "ps2/rileylink_ios" "v3.0" +github "ps2/rileylink_ios" "c33115c038a7d133234cfa8b73c80603ff10a599" diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index 67f6c603d4..19625b6b38 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -21,6 +21,7 @@ final class WatchContext: RawRepresentable { var glucose: HKQuantity? var glucoseTrendRawValue: Int? var glucoseDate: Date? + var glucoseSyncIdentifier: String? var predictedGlucose: WatchPredictedGlucose? var eventualGlucose: HKQuantity? { @@ -58,6 +59,7 @@ final class WatchContext: RawRepresentable { glucoseTrendRawValue = rawValue["gt"] as? Int glucoseDate = rawValue["gd"] as? Date + glucoseSyncIdentifier = rawValue["gs"] as? String iob = rawValue["iob"] as? Double reservoir = rawValue["r"] as? Double reservoirPercentage = rawValue["rp"] as? Double @@ -95,6 +97,7 @@ final class WatchContext: RawRepresentable { raw["gt"] = glucoseTrendRawValue raw["gd"] = glucoseDate + raw["gs"] = glucoseSyncIdentifier raw["iob"] = iob raw["ld"] = loopLastRunDate raw["r"] = reservoir @@ -117,3 +120,12 @@ extension WatchContext { } } } + +extension WatchContext { + var newGlucoseSample: NewGlucoseSample? { + if let quantity = glucose, let date = glucoseDate, let syncIdentifier = glucoseSyncIdentifier { + return NewGlucoseSample(date: date, quantity: quantity, isDisplayOnly: false, syncIdentifier: syncIdentifier, syncVersion: 0) + } + return nil + } +} diff --git a/Common/sv.lproj/Intents.strings b/Common/sv.lproj/Intents.strings index f4ec534f26..83ee6bdcf5 100644 --- a/Common/sv.lproj/Intents.strings +++ b/Common/sv.lproj/Intents.strings @@ -5,5 +5,5 @@ "OcNxIj" = "Lägg till kolhydrater"; /* (No Comment) */ -"yc02Yq" = "Lägg till kolhydrater för att loopa"; +"yc02Yq" = "Lägg till kolhydrater för att kunna loopa"; diff --git a/Common/sv.lproj/Localizable.strings b/Common/sv.lproj/Localizable.strings index 6844bcc037..c86447edaa 100644 --- a/Common/sv.lproj/Localizable.strings +++ b/Common/sv.lproj/Localizable.strings @@ -20,13 +20,13 @@ "Maximum" = "Maximum"; /* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; +"mg/dL" = "mg/dl"; /* Placeholder for lower range entry */ "Minimum" = "Minimum"; /* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; +"mmol/L" = "mmol/l"; /* Lesson title */ "Modal Day" = "Genomsnittlig dag"; diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index f2c1b495cf..6637784f2b 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -347,7 +347,7 @@ class RecommendTempBasalTests: XCTestCase { lastTempBasal: nil ) - XCTAssertEqual(1.425, dose!.unitsPerHour, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.60, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } @@ -365,7 +365,7 @@ class RecommendTempBasalTests: XCTestCase { lastTempBasal: nil ) - XCTAssertEqual(1.475, dose!.unitsPerHour, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.60, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } @@ -588,7 +588,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(1.575, dose.amount) + XCTAssertEqual(1.7, dose.amount) if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) @@ -657,7 +657,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(1.4, dose.amount) + XCTAssertEqual(1.575, dose.amount) XCTAssertEqual(BolusRecommendationNotice.predictedGlucoseBelowTarget(minGlucose: glucose[1]), dose.notice!) } @@ -676,7 +676,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(0.575, dose.amount) + XCTAssertEqual(0.7, dose.amount) } func testStartVeryLowEndHigh() { @@ -708,7 +708,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.575, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.7, dose.amount, accuracy: 1.0 / 40.0) } func testHighAndFalling() { @@ -724,7 +724,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(0.4, dose.amount, accuracy: 1.0 / 40.0) } func testInRangeAndRising() { @@ -740,7 +740,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(0.4, dose.amount, accuracy: 1.0 / 40.0) // Less existing temp @@ -771,7 +771,7 @@ class RecommendBolusTests: XCTestCase { volumeRounder: fortyIncrementsPerUnitRounder ) - XCTAssertEqual(0.275, dose.amount) + XCTAssertEqual(0.375, dose.amount) } func testHighAndRising() { @@ -787,7 +787,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.25, dose.amount) + XCTAssertEqual(1.35, dose.amount, accuracy: 1.0 / 40.0) // Use mmol sensitivity value let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiter, dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 10.0 / 3)])! @@ -802,7 +802,7 @@ class RecommendBolusTests: XCTestCase { maxBolus: maxBolus ) - XCTAssertEqual(1.25, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqual(1.35, dose.amount, accuracy: 1.0 / 40.0) } func testRiseAfterDIA() { diff --git a/DoseMathTests/sv.lproj/Localizable.strings b/DoseMathTests/sv.lproj/Localizable.strings index 13db793a4f..b992061cd5 100644 --- a/DoseMathTests/sv.lproj/Localizable.strings +++ b/DoseMathTests/sv.lproj/Localizable.strings @@ -1,5 +1,5 @@ /* Message when offering bolus recommendation even though bg is below range. (1: glucose value) */ -"Current glucose of %1$@ is below correction range." = "Current glucose of %1$@ is below correction range."; +"Current glucose of %1$@ is below correction range." = "Nuvarande glukosvärde %1$@ är under målvärdet."; /* The short unit display string for decibles */ "dB" = "dB"; @@ -8,16 +8,16 @@ "g" = "g"; /* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; +"mg/dL" = "mg/dl"; /* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; +"mmol/L" = "mmol/l"; /* Message when offering bolus recommendation even though bg is below range and minBG is in future. (1: glucose time)(2: glucose number) */ -"Predicted glucose at %1$@ is %2$@." = "Predicted glucose at %1$@ is %2$@."; +"Predicted glucose at %1$@ is %2$@." = "Förväntat glukosvärde vid %1$@ är %2$@."; /* Notice message when recommending bolus when BG is below the suspend threshold. (1: glucose value) */ -"Predicted glucose of %1$@ is below your suspend threshold setting." = "Predicted glucose of %1$@ is below your suspend threshold setting."; +"Predicted glucose of %1$@ is below your suspend threshold setting." = "Det förväntade slutglukosvärdet %1$@ är under ditt angivna tröskelvärde."; /* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ "QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; diff --git a/Learn/Info.plist b/Learn/Info.plist index cd88cba1d1..df6adf9510 100644 --- a/Learn/Info.plist +++ b/Learn/Info.plist @@ -25,7 +25,7 @@ LSRequiresIPhoneOS NSHealthShareUsageDescription - Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. + Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/Learn/fi.lproj/InfoPlist.strings b/Learn/fi.lproj/InfoPlist.strings new file mode 100644 index 0000000000..228ff6a032 --- /dev/null +++ b/Learn/fi.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Terveys-sovelluksen ateriatietoja käytetään glukoosivaikutusten määrittämiseen. Terveys-sovelluksen glukoositietoja käytetään graafeissa ja laskelmissa."; + diff --git a/Learn/sv.lproj/Localizable.strings b/Learn/sv.lproj/Localizable.strings index d4f055f26c..ac2501e87b 100644 --- a/Learn/sv.lproj/Localizable.strings +++ b/Learn/sv.lproj/Localizable.strings @@ -26,7 +26,7 @@ "Time in Range" = "Tid inom målvärde"; /* Lesson subtitle */ -"Visualizes the most frequent glucose values by time of day" = "Visar de vanligaste glukosvärdena under olika tider på dagen"; +"Visualizes the most frequent glucose values by time of day" = "Visar de vanligaste glukosvärdena under olika tider på dygnet"; /* Unit string for a count of calendar weeks */ "Weeks" = "Veckor"; diff --git a/Learn/sv.lproj/Main.strings b/Learn/sv.lproj/Main.strings index 50fa41e306..fe2ae42d97 100644 --- a/Learn/sv.lproj/Main.strings +++ b/Learn/sv.lproj/Main.strings @@ -1,3 +1,3 @@ /* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ -"8hF-Ij-B7m.title" = "Learn"; +"8hF-Ij-B7m.title" = "Utbildning"; diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift index f009729b5d..44fc0793b0 100644 --- a/Loop Status Extension/StatusViewController.swift +++ b/Loop Status Extension/StatusViewController.swift @@ -256,11 +256,12 @@ class StatusViewController: UIViewController, NCWidgetProviding { return } - if let lastGlucose = glucose.last { + if let lastGlucose = glucose.last, let recencyInterval = defaults.loopSettings?.inputDataRecencyInterval { self.hudView.glucoseHUD.setGlucoseQuantity( lastGlucose.quantity.doubleValue(for: unit), at: lastGlucose.startDate, unit: unit, + staleGlucoseAge: recencyInterval, sensor: context.sensor ) } diff --git a/Loop Status Extension/sv.lproj/Localizable.strings b/Loop Status Extension/sv.lproj/Localizable.strings index 630360f562..ec07cf13d5 100644 --- a/Loop Status Extension/sv.lproj/Localizable.strings +++ b/Loop Status Extension/sv.lproj/Localizable.strings @@ -1,5 +1,5 @@ /* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventuellt %1$@"; +"Eventually %1$@" = "Förväntat %1$@"; /* The subtitle format describing units of active insulin. (1: localized insulin value description) */ "IOB %1$@ U" = "IOB %1$@ E"; diff --git a/Loop Status Extension/sv.lproj/MainInterface.strings b/Loop Status Extension/sv.lproj/MainInterface.strings index 89e8f748e8..d826dbc61a 100644 --- a/Loop Status Extension/sv.lproj/MainInterface.strings +++ b/Loop Status Extension/sv.lproj/MainInterface.strings @@ -1,5 +1,5 @@ /* Class = "UILabel"; text = "Eventually 92 mg/dL"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Eventuellt 92 mg/dL"; +"9iF-xY-Bh4.text" = "Förväntat 5,1 mmol/l"; /* Class = "UILabel"; text = "IOB 1.0 U"; ObjectID = "UPi-dG-yYD"; */ "UPi-dG-yYD.text" = "IOB 1.0 E"; diff --git a/Loop.xcconfig b/Loop.xcconfig index f1333b1564..bbd285e6dc 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -11,7 +11,7 @@ MAIN_APP_BUNDLE_IDENTIFIER = com.${DEVELOPMENT_TEAM}.loopkit MAIN_APP_DISPLAY_NAME = Loop -LOOP_MARKETING_VERSION = 2.0 +LOOP_MARKETING_VERSION = 2.2.0 APPICON_NAME = AppIcon diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2be8f19cba..155507d692 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -29,7 +29,6 @@ 430B29952041F5CB00BA9F93 /* LoopSettings+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B29942041F5CB00BA9F93 /* LoopSettings+Loop.swift */; }; 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */; }; 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */; }; - 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */; }; 4315D28A1CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */; }; 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */; }; 431EA87021EB29120076EC1A /* ExponentialInsulinModelPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435CB6241F37ABFC00C320C7 /* ExponentialInsulinModelPreset.swift */; }; @@ -181,7 +180,6 @@ 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CB2B2A1D924D450079823D /* WCSession.swift */; }; 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */; }; 43CEE6E61E56AFD400CB9116 /* NightscoutUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CEE6E51E56AFD400CB9116 /* NightscoutUploader.swift */; }; - 43D2E8231F00425400AE5CBF /* BolusViewController+LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D2E8221F00425400AE5CBF /* BolusViewController+LoopDataManager.swift */; }; 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */; }; 43D9000B21EB0BE000AF44BF /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; }; 43D9001E21EB209400AF44BF /* LoopCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 43D9FFD121EAE05D00AF44BF /* LoopCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -336,11 +334,13 @@ 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7D9BEEF32335CF8D005DCFD6 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D9BEEF52335CF8D005DCFD6 /* Localizable.strings */; }; + 80F864E62433BF5D0026EC26 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 80F864E42433BF5D0026EC26 /* InfoPlist.strings */; }; 892A5D2A222EF60A008961AB /* MockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 892A5D29222EF60A008961AB /* MockKit.framework */; }; 892A5D2C222EF60A008961AB /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 892A5D2B222EF60A008961AB /* MockKitUI.framework */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 892A5D5B222F0D7C008961AB /* LoopTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */; }; 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */; }; + 892D7C5123B54A15008A9656 /* CarbEntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892D7C5023B54A14008A9656 /* CarbEntryViewController.swift */; }; 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CC22040104005293EC /* OverridePresetRow.swift */; }; 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */; }; 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */; }; @@ -353,6 +353,7 @@ 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */; }; 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */; }; 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */; }; + 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */; }; 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; 89E267FF229267DF00A3F2AF /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FE229267DF00A3F2AF /* Optional.swift */; }; @@ -375,6 +376,8 @@ C17824A61E1AF91F00D9D25C /* BolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* BolusRecommendation.swift */; }; C1814B86225E507C008D2D8E /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1814B85225E507C008D2D8E /* Sequence.swift */; }; C18C8C511D5A351900E043FB /* NightscoutDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */; }; + C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; + C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; C1A3EED2235233E1007672E3 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C1A3EED1235233E1007672E3 /* DerivedAssets.xcassets */; }; C1A3EED423523551007672E3 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C1A3EED323523551007672E3 /* DerivedAssets.xcassets */; }; C1A3EED523535FFF007672E3 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 894F71E11FFEC4D8007D365C /* DefaultAssets.xcassets */; }; @@ -389,6 +392,7 @@ C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; + E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -578,7 +582,6 @@ 430DA58D1D4AEC230097D1CA /* NSBundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSBundle.swift; sourceTree = ""; }; 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TitleSubtitleTextFieldTableViewCell.swift; sourceTree = ""; }; 4313EDDF1D8A6BF90060FA79 /* ChartContainerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ChartContainerView.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryEditTableViewController.swift; sourceTree = ""; }; 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DiagnosticLogger+LoopKit.swift"; sourceTree = ""; }; 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircleMaskView.swift; sourceTree = ""; }; 431E73471FF95A900069B5F7 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; @@ -713,7 +716,6 @@ 43CB2B2A1D924D450079823D /* WCSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WCSession.swift; sourceTree = ""; }; 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; 43CEE6E51E56AFD400CB9116 /* NightscoutUploader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutUploader.swift; sourceTree = ""; }; - 43D2E8221F00425400AE5CBF /* BolusViewController+LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BolusViewController+LoopDataManager.swift"; sourceTree = ""; }; 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderValuesTableViewCell.swift; sourceTree = ""; }; 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "WatchApp Extension.entitlements"; sourceTree = ""; }; 43D848AF1E7DCBE100DADCBC /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; @@ -1040,11 +1042,13 @@ 7DD382771F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; 7DD382781F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/MainInterface.strings; sourceTree = ""; }; 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; + 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D2B222EF60A008961AB /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKitUI.framework; path = Carthage/Build/iOS/MockKitUI.framework; sourceTree = SOURCE_ROOT; }; 892A5D58222F0A27008961AB /* Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = ""; }; 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = LoopTestingKit.framework; path = Carthage/Build/iOS/LoopTestingKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeReplaceableCollection.swift; sourceTree = ""; }; + 892D7C5023B54A14008A9656 /* CarbEntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryViewController.swift; sourceTree = ""; }; 892FB4CC22040104005293EC /* OverridePresetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetRow.swift; sourceTree = ""; }; 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideSelectionController.swift; sourceTree = ""; }; 894F71E11FFEC4D8007D365C /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; @@ -1060,6 +1064,7 @@ 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryObserver.swift; sourceTree = ""; }; 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosTableViewController.swift; sourceTree = ""; }; 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTestingScenariosManager.swift; sourceTree = ""; }; + 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryTableViewCell.swift; sourceTree = ""; }; 89E267FB2292456700A3F2AF /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; 89E267FE229267DF00A3F2AF /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = NightscoutUploadKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1086,6 +1091,7 @@ C18A491422FCC22900FDA733 /* build-derived-watch-assets.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "build-derived-watch-assets.sh"; sourceTree = ""; }; C18A491522FCC22900FDA733 /* copy-plugins.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "copy-plugins.sh"; sourceTree = ""; }; C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutDataManager.swift; sourceTree = ""; }; + C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshness.swift; sourceTree = ""; }; C1A3EED1235233E1007672E3 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; C1A3EED323523551007672E3 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; C1C6591B1E1B1FDA0025CC58 /* recommend_temp_basal_dropping_then_rising.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_dropping_then_rising.json; sourceTree = ""; }; @@ -1097,6 +1103,7 @@ C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; + E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1492,6 +1499,7 @@ 43D9FFAB21EA9A0F00AF44BF /* Assets.xcassets */, 43D9FFAD21EA9A0F00AF44BF /* LaunchScreen.storyboard */, 43D9FFB021EA9A0F00AF44BF /* Info.plist */, + 80F864E42433BF5D0026EC26 /* InfoPlist.strings */, 7D9BEEE72335A6B3005DCFD6 /* Localizable.strings */, ); path = Learn; @@ -1515,6 +1523,7 @@ 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, 430B298C2041F56500BA9F93 /* LoopSettings.swift */, + C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, 43D848AF1E7DCBE100DADCBC /* Result.swift */, @@ -1592,9 +1601,8 @@ isa = PBXGroup; children = ( 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */, - 43D2E8221F00425400AE5CBF /* BolusViewController+LoopDataManager.swift */, 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */, - 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */, + 892D7C5023B54A14008A9656 /* CarbEntryViewController.swift */, 43DBF0581C93F73800B3C386 /* CarbEntryTableViewController.swift */, 43A51E201EB6DBDD000736CC /* ChartsTableViewController.swift */, 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */, @@ -1625,6 +1633,7 @@ 43C3B6EB20B650A80026CAFA /* SettingsImageTableViewCell.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, + 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, ); path = Views; @@ -1649,6 +1658,7 @@ 4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */, 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, + E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, ); path = Managers; sourceTree = ""; @@ -2330,6 +2340,7 @@ 43D9FFAF21EA9A0F00AF44BF /* LaunchScreen.storyboard in Resources */, 43D9FFAC21EA9A0F00AF44BF /* Assets.xcassets in Resources */, 43D9FFAA21EA9A0C00AF44BF /* Main.storyboard in Resources */, + 80F864E62433BF5D0026EC26 /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2626,7 +2637,9 @@ 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */, 4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */, 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, + E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, + 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, @@ -2646,6 +2659,7 @@ 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, + 892D7C5123B54A15008A9656 /* CarbEntryViewController.swift in Sources */, 43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, @@ -2660,7 +2674,6 @@ 438849EA1D297CB6003B3F23 /* NightscoutService.swift in Sources */, 438172D91F4E9E37003C3328 /* NewPumpEvent.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, - 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, 43DBF04C1C93B8D700B3C386 /* BolusViewController.swift in Sources */, 4FB76FBB1E8C42CF00B39636 /* UIColor.swift in Sources */, @@ -2685,7 +2698,6 @@ 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, C16DA84222E8E112008624C2 /* LoopPlugins.swift in Sources */, - 43D2E8231F00425400AE5CBF /* BolusViewController+LoopDataManager.swift in Sources */, 430B29952041F5CB00BA9F93 /* LoopSettings+Loop.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 43CEE6E61E56AFD400CB9116 /* NightscoutUploader.swift in Sources */, @@ -2786,6 +2798,7 @@ files = ( 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, + C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, 431EA87221EB29150076EC1A /* InsulinModelSettings.swift in Sources */, @@ -2838,6 +2851,7 @@ files = ( 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, + C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, 431EA87321EB29160076EC1A /* InsulinModelSettings.swift in Sources */, @@ -3355,6 +3369,14 @@ name = Localizable.strings; sourceTree = ""; }; + 80F864E42433BF5D0026EC26 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 80F864E52433BF5D0026EC26 /* fi */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 401ae1c467..f2f0e4a612 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -50,11 +50,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { NotificationManager.authorize(delegate: self) - let mainStatusViewControllers = UIStoryboard(name: "Main", bundle: Bundle(for: AppDelegate.self)).instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController + let mainStatusViewController = UIStoryboard(name: "Main", bundle: Bundle(for: AppDelegate.self)).instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController - rootViewController.pushViewController(mainStatusViewControllers, animated: false) - - rootViewController.rootViewController.deviceManager = deviceManager + mainStatusViewController.deviceManager = deviceManager + + rootViewController.pushViewController(mainStatusViewController, animated: false) } diff --git a/Loop/Base.lproj/InfoPlist.strings b/Loop/Base.lproj/InfoPlist.strings index dc7c061704..c179f1ede9 100644 --- a/Loop/Base.lproj/InfoPlist.strings +++ b/Loop/Base.lproj/InfoPlist.strings @@ -11,7 +11,7 @@ "NSFaceIDUsageDescription" = "Face ID is used to authenticate insulin bolus."; /* Privacy - Health Share Usage Description */ -"NSHealthShareUsageDescription" = "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation."; +"NSHealthShareUsageDescription" = "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to improve the Apple Watch complication."; /* Privacy - Health Update Usage Description */ "NSHealthUpdateUsageDescription" = "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit."; diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index b59845e58e..07ba662f49 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -448,19 +448,19 @@ - + - + - + - + @@ -514,7 +514,7 @@ - + @@ -526,7 +526,7 @@ - + @@ -664,7 +664,7 @@ - + @@ -700,7 +700,7 @@ - + @@ -708,67 +708,109 @@ - - + + - - + + - - - - - + + + + + + + + + + - + + + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + - - - - - + @@ -814,20 +856,20 @@ - + - + @@ -839,7 +881,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + @@ -933,17 +940,7 @@ - - - - - - - - - - - + @@ -974,7 +971,7 @@ - + @@ -1113,8 +1110,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1125,9 +1325,4 @@ - - - - - diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json new file mode 100644 index 0000000000..7bff2dbe3a --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json @@ -0,0 +1,53 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Oval Selection.pdf", + "resizing" : { + "mode" : "9-part", + "center" : { + "mode" : "tile", + "width" : 1, + "height" : 1 + }, + "cap-insets" : { + "bottom" : 10, + "top" : 10, + "right" : 10, + "left" : 10 + } + } + }, + { + "idiom" : "universal", + "filename" : "Oval Selection Dark.pdf", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "resizing" : { + "mode" : "9-part", + "center" : { + "mode" : "tile", + "width" : 1, + "height" : 1 + }, + "cap-insets" : { + "bottom" : 10, + "top" : 10, + "right" : 10, + "left" : 10 + } + } + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf new file mode 100644 index 0000000000..a67042ecbf Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf differ diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf new file mode 100644 index 0000000000..13056f028e Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf differ diff --git a/Loop/Info.plist b/Loop/Info.plist index f8907e07d0..d7bee421c3 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -50,7 +50,7 @@ NSFaceIDUsageDescription Face ID is used to authenticate insulin bolus. NSHealthShareUsageDescription - Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. + Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. NSHealthUpdateUsageDescription Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. NSUserActivityTypes diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 194382ec2b..74a0486010 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -35,6 +35,8 @@ final class DeviceDataManager { /// The last time a BLE heartbeat was received and acted upon. private var lastBLEDrivenUpdate = Date.distantPast + + private var deviceLog: PersistentDeviceLog // MARK: - CGM @@ -86,6 +88,11 @@ final class DeviceDataManager { init() { pluginManager = PluginManager() + + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let deviceLogDirectory = documentsDirectory.appendingPathComponent("DeviceLog") + deviceLog = PersistentDeviceLog(storageFile: deviceLogDirectory.appendingPathComponent("Storage.sqlite")) if let pumpManagerRawValue = UserDefaults.appGroup?.pumpManagerRawValue { pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) @@ -189,7 +196,51 @@ final class DeviceDataManager { updatePumpManagerBLEHeartbeatPreference() } - + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + self.loopManager.generateDiagnosticReport { (loopReport) in + self.deviceLog.getLogEntries(startDate: Date() - .hours(48)) { (result) in + let deviceLogReport: String + switch result { + case .failure(let error): + deviceLogReport = "Error fetching entries: \(error)" + case .success(let entries): + deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") + } + + let report = [ + Bundle.main.localizedNameAndVersion, + "* gitRevision: \(Bundle.main.gitRevision ?? "N/A")", + "* gitBranch: \(Bundle.main.gitBranch ?? "N/A")", + "* sourceRoot: \(Bundle.main.sourceRoot ?? "N/A")", + "* buildDateString: \(Bundle.main.buildDateString ?? "N/A")", + "* xcodeVersion: \(Bundle.main.xcodeVersion ?? "N/A")", + "", + "## FeatureFlags", + "\(FeatureFlags)", + "", + "## DeviceDataManager", + "* launchDate: \(self.launchDate)", + "* lastError: \(String(describing: self.lastError))", + "* lastBLEDrivenUpdate: \(self.lastBLEDrivenUpdate)", + "", + self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", + "", + self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", + "", + "## Device Communication Log", + deviceLogReport, + "", + String(reflecting: self.watchManager!), + "", + String(reflecting: self.statusExtensionManager!), + "", + loopReport, + ].joined(separator: "\n") + + completion(report) + } + } + } } private extension DeviceDataManager { @@ -274,6 +325,7 @@ extension DeviceDataManager: RemoteDataManagerDelegate { // MARK: - DeviceManagerDelegate extension DeviceDataManager: DeviceManagerDelegate { + func scheduleNotification(for manager: DeviceManager, identifier: String, content: UNNotificationContent, @@ -290,6 +342,10 @@ extension DeviceDataManager: DeviceManagerDelegate { func clearNotification(for manager: DeviceManager, identifier: String) { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) } + + func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { + deviceLog.log(managerIdentifier: Swift.type(of: manager).managerIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + } } // MARK: - CGMManagerDelegate @@ -620,34 +676,6 @@ extension DeviceDataManager: LoopDataManagerDelegate { } } - -// MARK: - CustomDebugStringConvertible -extension DeviceDataManager: CustomDebugStringConvertible { - var debugDescription: String { - return [ - Bundle.main.localizedNameAndVersion, - "* gitRevision: \(Bundle.main.gitRevision ?? "N/A")", - "* gitBranch: \(Bundle.main.gitBranch ?? "N/A")", - "* sourceRoot: \(Bundle.main.sourceRoot ?? "N/A")", - "* buildDateString: \(Bundle.main.buildDateString ?? "N/A")", - "* xcodeVersion: \(Bundle.main.xcodeVersion ?? "N/A")", - "", - "## DeviceDataManager", - "* launchDate: \(launchDate)", - "* lastError: \(String(describing: lastError))", - "* lastBLEDrivenUpdate: \(lastBLEDrivenUpdate)", - "", - cgmManager != nil ? String(reflecting: cgmManager!) : "cgmManager: nil", - "", - pumpManager != nil ? String(reflecting: pumpManager!) : "pumpManager: nil", - "", - String(reflecting: watchManager!), - "", - String(reflecting: statusExtensionManager!), - ].joined(separator: "\n") - } -} - extension Notification.Name { static let PumpManagerChanged = Notification.Name(rawValue: "com.loopKit.notification.PumpManagerChanged") static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index 5502084671..b10e4450c0 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -267,7 +267,8 @@ extension Collection where Element: GlucoseValue { // Compute the dose required to bring this prediction to target: // dose = (Glucose Δ) / (% effect × sensitivity) - let percentEffected = 1 - model.percentEffectRemaining(at: time) + // For 0 <= time <= effectDelay, assume a small amount effected. This will result in large unit recommendation rather than no recommendation at all. + let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time)) let effectedSensitivity = percentEffected * sensitivityValue guard let correctionUnits = insulinCorrectionUnits( fromValue: predictedGlucoseValue, @@ -299,7 +300,7 @@ extension Collection where Element: GlucoseValue { eventual.quantity < eventualGlucoseTargets.lowerBound { let time = min.startDate.timeIntervalSince(date) - // For time = 0, assume a small amount effected. This will result in large (negative) unit recommendation rather than no recommendation at all. + // For 0 <= time <= effectDelay, assume a small amount effected. This will result in large (negative) unit recommendation rather than no recommendation at all. let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time)) guard let units = insulinCorrectionUnits( diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 5bbc7611c6..e02ce7174d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -65,6 +65,7 @@ final class LoopDataManager { carbStore = CarbStore( healthStore: healthStore, cacheStore: cacheStore, + cacheLength: .hours(24), defaultAbsorptionTimes: LoopSettings.defaultCarbAbsorptionTimes, carbRatioSchedule: carbRatioSchedule, insulinSensitivitySchedule: insulinSensitivitySchedule, @@ -100,6 +101,7 @@ final class LoopDataManager { self.carbEffect = nil self.carbsOnBoard = nil + self.recentCarbEntries = nil self.notify(forChange: .carbs) } }, @@ -168,36 +170,55 @@ final class LoopDataManager { retrospectiveGlucoseDiscrepancies = nil } } + private var insulinEffect: [GlucoseEffect]? { didSet { + insulinEffectIncludingPendingInsulin = nil predictedGlucose = nil } } + + private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { + didSet { + predictedGlucoseIncludingPendingInsulin = nil + } + } + private var glucoseMomentumEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil } } + private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { didSet { predictedGlucose = nil } } + /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. + private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 + private var retrospectiveGlucoseDiscrepancies: [GlucoseEffect]? { didSet { - retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: settings.retrospectiveCorrectionGroupingInterval * 1.01) + retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: settings.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) } } + private var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]? fileprivate var predictedGlucose: [PredictedGlucoseValue]? { didSet { recommendedTempBasal = nil recommendedBolus = nil + predictedGlucoseIncludingPendingInsulin = nil } } + fileprivate var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? + + private var recentCarbEntries: [StoredCarbEntry]? + fileprivate var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? fileprivate var recommendedBolus: (recommendation: BolusRecommendation, date: Date)? @@ -419,8 +440,18 @@ extension LoopDataManager { } } - /// All the HealthKit types to be read and shared by stores - private var sampleTypes: Set { + /// All the HealthKit types to be read by stores + private var readTypes: Set { + return Set([ + glucoseStore.sampleType, + carbStore.sampleType, + doseStore.sampleType, + HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! + ].compactMap { $0 }) + } + + /// All the HealthKit types to be shared by stores + private var shareTypes: Set { return Set([ glucoseStore.sampleType, carbStore.sampleType, @@ -428,23 +459,33 @@ extension LoopDataManager { ].compactMap { $0 }) } + var sleepDataAuthorizationRequired: Bool { + return carbStore.healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .notDetermined + } + + var sleepDataSharingDenied: Bool { + return carbStore.healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .sharingDenied + } + /// True if any stores require HealthKit authorization var authorizationRequired: Bool { return glucoseStore.authorizationRequired || carbStore.authorizationRequired || - doseStore.authorizationRequired + doseStore.authorizationRequired || + sleepDataAuthorizationRequired } /// True if the user has explicitly denied access to any stores' HealthKit types private var sharingDenied: Bool { return glucoseStore.sharingDenied || carbStore.sharingDenied || - doseStore.sharingDenied + doseStore.sharingDenied || + sleepDataSharingDenied } func authorize(_ completion: @escaping () -> Void) { // Authorize all types at once for simplicity - carbStore.healthStore.requestAuthorization(toShare: sampleTypes, read: sampleTypes) { (success, error) in + carbStore.healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { (success, error) in if success { // Call the individual authorization methods to trigger query creation self.carbStore.authorize({ _ in }) @@ -685,7 +726,7 @@ extension LoopDataManager { // Fetch glucose effects as far back as we want to make retroactive analysis var latestGlucoseDate: Date? updateGroup.enter() - glucoseStore.getCachedGlucoseSamples(start: Date(timeIntervalSinceNow: -settings.recencyInterval)) { (values) in + glucoseStore.getCachedGlucoseSamples(start: Date(timeIntervalSinceNow: -settings.inputDataRecencyInterval)) { (values) in latestGlucoseDate = values.last?.startDate updateGroup.leave() } @@ -724,6 +765,21 @@ extension LoopDataManager { } } + if insulinEffectIncludingPendingInsulin == nil { + updateGroup.enter() + doseStore.getGlucoseEffects(start: nextEffectDate, basalDosingEnd: nil) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("Could not fetch insulin effects: \(error)") + self.insulinEffectIncludingPendingInsulin = nil + case .success(let effects): + self.insulinEffectIncludingPendingInsulin = effects + } + + updateGroup.leave() + } + } + _ = updateGroup.wait(timeout: .distantFuture) if nextEffectDate < lastGlucoseDate, let insulinEffect = insulinEffect { @@ -749,8 +805,10 @@ extension LoopDataManager { case .failure(let error): self.logger.error(error) self.carbEffect = nil - case .success(let effects): + self.recentCarbEntries = nil + case .success(let (samples, effects)): self.carbEffect = effects + self.recentCarbEntries = samples } updateGroup.leave() @@ -833,8 +891,18 @@ extension LoopDataManager { return pendingTempBasalInsulin + pendingBolusAmount } - /// - Throws: LoopError.missingDataError - fileprivate func predictGlucose(using inputs: PredictionInputEffect) throws -> [PredictedGlucoseValue] { + /// - Throws: + /// - LoopError.missingDataError + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.pumpDataTooOld + fileprivate func predictGlucose( + using inputs: PredictionInputEffect, + potentialBolus: DoseEntry? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, + includingPendingInsulin: Bool = false + ) throws -> [PredictedGlucoseValue] { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) guard let model = insulinModelSettings?.model else { @@ -845,15 +913,76 @@ extension LoopDataManager { throw LoopError.missingDataError(.glucose) } + let pumpStatusDate = doseStore.lastAddedPumpData + let lastGlucoseDate = glucose.startDate + let now = Date() + + guard now.timeIntervalSince(lastGlucoseDate) <= settings.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard now.timeIntervalSince(pumpStatusDate) <= settings.inputDataRecencyInterval else { + throw LoopError.pumpDataTooOld(date: pumpStatusDate) + } + var momentum: [GlucoseEffect] = [] + var retrospectiveGlucoseEffect = self.retrospectiveGlucoseEffect var effects: [[GlucoseEffect]] = [] if inputs.contains(.carbs), let carbEffect = self.carbEffect { - effects.append(carbEffect) + if let potentialCarbEntry = potentialCarbEntry, var recentEntries = recentCarbEntries { + if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { + recentEntries.remove(at: index) + } + + let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-retrospectiveCorrection.retrospectionInterval) + + if potentialCarbEntry.startDate > lastGlucoseDate, replacedCarbEntry == nil { + // The potential carb effect is independent and can be summed with the existing effect + effects.append(carbEffect) + let potentialCarbEffect = try carbStore.glucoseEffects( + of: [potentialCarbEntry], + startingAt: retrospectiveStart, + effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil + ) + + effects.append(potentialCarbEffect) + } else { + // If the entry is in the past or an entry is replaced, DCA and RC effects must be recomputed + var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } + entries.append(potentialCarbEntry) + entries.sort(by: { $0.startDate > $1.startDate }) + + let potentialCarbEffect = try carbStore.glucoseEffects( + of: entries, + startingAt: retrospectiveStart, + effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil + ) + + effects.append(potentialCarbEffect) + + retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) + } + } else { + effects.append(carbEffect) + } } - if inputs.contains(.insulin), let insulinEffect = self.insulinEffect { + if inputs.contains(.insulin), let insulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : self.insulinEffect { effects.append(insulinEffect) + + if let potentialBolus = potentialBolus { + guard let sensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { + throw LoopError.configurationError(.generalSettings) + } + + let earliestEffectDate = Date(timeIntervalSinceNow: .hours(-24)) + let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate + let bolusEffect = [potentialBolus] + .glucoseEffects(insulinModel: model, insulinSensitivity: sensitivity) + .filterDateRange(nextEffectDate, nil) + effects.append(bolusEffect) + } } if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { @@ -861,7 +990,7 @@ extension LoopDataManager { } if inputs.contains(.retrospection) { - effects.append(self.retrospectiveGlucoseEffect) + effects.append(retrospectiveGlucoseEffect) } var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) @@ -876,6 +1005,87 @@ extension LoopDataManager { return prediction } + /// - Throws: LoopError.missingDataError + fileprivate func recommendBolus(forPrediction predictedGlucose: [Sample]) throws -> BolusRecommendation? { + guard let glucose = glucoseStore.latestGlucose else { + throw LoopError.missingDataError(.glucose) + } + + let pumpStatusDate = doseStore.lastAddedPumpData + let lastGlucoseDate = glucose.startDate + let now = Date() + + guard now.timeIntervalSince(lastGlucoseDate) <= settings.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard now.timeIntervalSince(pumpStatusDate) <= settings.inputDataRecencyInterval else { + throw LoopError.pumpDataTooOld(date: pumpStatusDate) + } + + guard glucoseMomentumEffect != nil else { + throw LoopError.missingDataError(.momentumEffect) + } + + guard carbEffect != nil else { + throw LoopError.missingDataError(.carbEffect) + } + + guard insulinEffect != nil else { + throw LoopError.missingDataError(.insulinEffect) + } + + guard + let glucoseTargetRange = settings.glucoseTargetRangeScheduleApplyingOverrideIfActive, + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory, + let maxBolus = settings.maximumBolus, + let model = insulinModelSettings?.model + else { + throw LoopError.configurationError(.generalSettings) + } + + guard lastRequestedBolus == nil + else { + // Don't recommend changes if a bolus was just requested. + // Sending additional pump commands is not going to be + // successful in any case. + return nil + } + + let volumeRounder = { (_ units: Double) in + return self.delegate?.loopDataManager(self, roundBolusVolume: units) ?? units + } + + return predictedGlucose.recommendedBolus( + to: glucoseTargetRange, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity, + model: model, + pendingInsulin: 0, // Pending insulin is already reflected in the prediction + maxBolus: maxBolus, + volumeRounder: volumeRounder + ) + } + + fileprivate func computeCarbsOnBoard(potentialCarbEntry: NewCarbEntry?, replacing replacedCarbEntry: StoredCarbEntry?) -> CarbValue? { + var recentEntries = recentCarbEntries ?? [] + if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { + recentEntries.remove(at: index) + } + + var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } + if let potentialCarbEntry = potentialCarbEntry { + entries.append(potentialCarbEntry) + entries.sort(by: { $0.startDate > $1.startDate }) + } + + return try? carbStore.carbsOnBoard( + from: entries, + at: Date(), + effectVelocities: settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil + ) + } + /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. /// /// - Throws: LoopError.missingDataError @@ -902,7 +1112,21 @@ extension LoopDataManager { retrospectiveGlucoseEffect = retrospectiveCorrection.computeEffect( startingAt: glucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: settings.recencyInterval, + recencyInterval: settings.inputDataRecencyInterval, + insulinSensitivitySchedule: insulinSensitivitySchedule, + basalRateSchedule: basalRateSchedule, + glucoseCorrectionRangeSchedule: settings.glucoseTargetRangeSchedule, + retrospectiveCorrectionGroupingInterval: settings.retrospectiveCorrectionGroupingInterval + ) + } + + private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] { + let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) + let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: settings.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) + return retrospectiveCorrection.computeEffect( + startingAt: glucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: settings.inputDataRecencyInterval, insulinSensitivitySchedule: insulinSensitivitySchedule, basalRateSchedule: basalRateSchedule, glucoseCorrectionRangeSchedule: settings.glucoseTargetRangeSchedule, @@ -931,12 +1155,12 @@ extension LoopDataManager { let startDate = Date() - guard startDate.timeIntervalSince(glucose.startDate) <= settings.recencyInterval else { + guard startDate.timeIntervalSince(glucose.startDate) <= settings.inputDataRecencyInterval else { self.predictedGlucose = nil throw LoopError.glucoseTooOld(date: glucose.startDate) } - guard startDate.timeIntervalSince(pumpStatusDate) <= settings.recencyInterval else { + guard startDate.timeIntervalSince(pumpStatusDate) <= settings.inputDataRecencyInterval else { self.predictedGlucose = nil throw LoopError.pumpDataTooOld(date: pumpStatusDate) } @@ -951,13 +1175,15 @@ extension LoopDataManager { throw LoopError.missingDataError(.carbEffect) } - guard insulinEffect != nil else { + guard insulinEffect != nil, insulinEffectIncludingPendingInsulin != nil else { self.predictedGlucose = nil throw LoopError.missingDataError(.insulinEffect) } let predictedGlucose = try predictGlucose(using: settings.enabledEffects) self.predictedGlucose = predictedGlucose + let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true) + self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin guard let maxBasal = settings.maximumBasalRatePerHour, @@ -993,6 +1219,7 @@ extension LoopDataManager { let tempBasal = predictedGlucose.recommendedTempBasal( to: glucoseTargetRange, + at: predictedGlucose[0].startDate, suspendThreshold: settings.suspendThreshold?.quantity, sensitivity: insulinSensitivity, model: model, @@ -1011,18 +1238,17 @@ extension LoopDataManager { recommendedTempBasal = nil } - let pendingInsulin = try self.getPendingInsulin() - let volumeRounder = { (_ units: Double) in return self.delegate?.loopDataManager(self, roundBolusVolume: units) ?? units } - let recommendation = predictedGlucose.recommendedBolus( + let recommendation = predictedGlucoseIncludingPendingInsulin.recommendedBolus( to: glucoseTargetRange, + at: predictedGlucose[0].startDate, suspendThreshold: settings.suspendThreshold?.quantity, sensitivity: insulinSensitivity, model: model, - pendingInsulin: pendingInsulin, + pendingInsulin: 0, // Pending insulin is already reflected in the prediction maxBolus: maxBolus, volumeRounder: volumeRounder ) @@ -1073,6 +1299,9 @@ protocol LoopState { /// The calculated timeline of predicted glucose values var predictedGlucose: [PredictedGlucoseValue]? { get } + /// The calculated timeline of predicted glucose values, including the effects of pending insulin + var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { get } + /// The recommended temp basal based on predicted glucose var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? { get } @@ -1089,9 +1318,39 @@ protocol LoopState { /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. /// /// - Parameter inputs: The effect inputs to include + /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction + /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction + /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin + /// - Returns: An timeline of predicted glucose values + /// - Throws: LoopError.missingDataError if prediction cannot be computed + func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool) throws -> [PredictedGlucoseValue] + + /// Computes the recommended bolus for correcting a glucose prediction + /// - Parameter predictedGlucose: A timeline of predicted glucose values + /// - Returns: A bolus recommendation, or `nil` if not applicable + /// - Throws: LoopError.missingDataError if recommendation cannot be computed + func recommendBolus(forPrediction predictedGlucose: [Sample]) throws -> BolusRecommendation? + + /// Computes the carbs on board, taking into account an unstored carb entry + /// - Parameters: + /// - potentialCarbEntry: An unstored carb entry under consideration + /// - replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + func computeCarbsOnBoard(potentialCarbEntry: NewCarbEntry?, replacing replacedCarbEntry: StoredCarbEntry?) -> CarbValue? +} + +extension LoopState { + /// Calculates a new prediction from the current data using the specified effect inputs + /// + /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. + /// + /// - Parameter inputs: The effect inputs to include + /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin /// - Returns: An timeline of predicted glucose values /// - Throws: LoopError.missingDataError if prediction cannot be computed - func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] + func predictGlucose(using inputs: PredictionInputEffect, includingPendingInsulin: Bool = false) throws -> [GlucoseValue] { + try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil, includingPendingInsulin: includingPendingInsulin) + } } @@ -1125,6 +1384,11 @@ extension LoopDataManager { return loopDataManager.predictedGlucose } + var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.predictedGlucoseIncludingPendingInsulin + } + var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) guard loopDataManager.lastRequestedBolus == nil else { @@ -1151,8 +1415,16 @@ extension LoopDataManager { return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect } - func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { - return try loopDataManager.predictGlucose(using: inputs) + func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool) throws -> [PredictedGlucoseValue] { + return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin) + } + + func recommendBolus(forPrediction predictedGlucose: [Sample]) throws -> BolusRecommendation? { + return try loopDataManager.recommendBolus(forPrediction: predictedGlucose) + } + + func computeCarbsOnBoard(potentialCarbEntry: NewCarbEntry?, replacing replacedCarbEntry: StoredCarbEntry?) -> CarbValue? { + return loopDataManager.computeCarbsOnBoard(potentialCarbEntry: potentialCarbEntry, replacing: replacedCarbEntry) } } diff --git a/Loop/Managers/NightscoutDataManager.swift b/Loop/Managers/NightscoutDataManager.swift index f276d94b3f..90f04150cc 100644 --- a/Loop/Managers/NightscoutDataManager.swift +++ b/Loop/Managers/NightscoutDataManager.swift @@ -417,15 +417,20 @@ final class NightscoutDataManager { } else { deviceStr = "loop://unknowndevice" } + let direction: String? = { switch sensorState?.trendType { case .up?: + return "FortyFiveUp" + case .upUp?: return "SingleUp" - case .upUp?, .upUpUp?: + case .upUpUp?: return "DoubleUp" case .down?: + return "FortyFiveDown" + case .downDown?: return "SingleDown" - case .downDown?, .downDownDown?: + case .downDownDown?: return "DoubleDown" case .flat?: return "Flat" diff --git a/Loop/Managers/SleepStore.swift b/Loop/Managers/SleepStore.swift new file mode 100644 index 0000000000..38cedf115e --- /dev/null +++ b/Loop/Managers/SleepStore.swift @@ -0,0 +1,120 @@ +// +// SleepStore.swift +// Loop +// +// Created by Anna Quinlan on 12/28/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import os.log + +enum SleepStoreResult { + case success(T) + case failure(SleepStoreError) +} + +enum SleepStoreError: Error { + case noMatchingBedtime + case unknownReturnConfiguration + case noSleepDataAvailable + case queryError(String) // String is description of error +} + +class SleepStore { + var healthStore: HKHealthStore + + private let log = OSLog(category: "SleepStore") + + public init( + healthStore: HKHealthStore + ) { + self.healthStore = healthStore + } + + func getAverageSleepStartTime(sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { + let inBedPredicate = HKQuery.predicateForCategorySamples( + with: .equalTo, + value: HKCategoryValueSleepAnalysis.inBed.rawValue + ) + + let asleepPredicate = HKQuery.predicateForCategorySamples( + with: .equalTo, + value: HKCategoryValueSleepAnalysis.asleep.rawValue + ) + + getAverageSleepStartTime(matching: inBedPredicate, sampleLimit: sampleLimit) { + (result) in + switch result { + case .success(_): + completion(result) + case .failure(let error): + switch error { + case SleepStoreError.noSleepDataAvailable: + // if there were no .inBed samples, check if there are any .asleep samples that could be used to estimate bedtime + self.getAverageSleepStartTime(matching: asleepPredicate, sampleLimit: sampleLimit, completion) + default: + // otherwise, call completion + completion(result) + } + } + + } + } + + fileprivate func getAverageSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { + let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! + + // get more-recent values first + let sortByDate = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) + + let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in + + if let error = error { + self.log.error("Error fetching sleep data: %{public}@", String(describing: error)) + completion(.failure(SleepStoreError.queryError(error.localizedDescription))) + } else if let samples = samples as? [HKCategorySample] { + guard !samples.isEmpty else { + completion(.failure(SleepStoreError.noSleepDataAvailable)) + return + } + + // find the average hour and minute components from the sleep start times + let average = samples.reduce(0, { + if let metadata = $1.metadata, let timezone = metadata[HKMetadataKeyTimeZone] { + return $0 + $1.startDate.timeOfDayInSeconds(sampleTimeZone: NSTimeZone(name: timezone as! String)! as TimeZone) + } else { + // default to the current timezone if the sample does not contain one in its metadata + return $0 + $1.startDate.timeOfDayInSeconds(sampleTimeZone: Calendar.current.timeZone) + } + }) / samples.count + + let averageHour = average / 3600 + let averageMinute = average % 3600 / 60 + + // find the next time that the user will go to bed, based on the averages we've computed + if let bedtime = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime), bedtime.timeIntervalSinceNow <= .hours(24) { + completion(.success(bedtime)) + } else { + completion(.failure(SleepStoreError.noMatchingBedtime)) + } + } else { + completion(.failure(SleepStoreError.unknownReturnConfiguration)) + } + } + healthStore.execute(query) + } +} + +extension Date { + fileprivate func timeOfDayInSeconds(sampleTimeZone: TimeZone) -> Int { + var calendar = Calendar.current + calendar.timeZone = sampleTimeZone + + let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self) + let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second! + + return dateSeconds + } +} diff --git a/Loop/Managers/StatusChartsManager.swift b/Loop/Managers/StatusChartsManager.swift index d54d512797..a4ab76141c 100644 --- a/Loop/Managers/StatusChartsManager.swift +++ b/Loop/Managers/StatusChartsManager.swift @@ -104,7 +104,7 @@ extension StatusChartsManager { extension StatusChartsManager { func setDoseEntries(_ doseEntries: [DoseEntry]) { - dose.setDoseEntries(doseEntries) + dose.doseEntries = doseEntries invalidateChart(atIndex: ChartIndex.dose.rawValue) } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 34f563fa57..ffeee3a2ba 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -12,13 +12,15 @@ import WatchConnectivity import LoopKit import LoopCore - final class WatchDataManager: NSObject { unowned let deviceManager: DeviceDataManager init(deviceManager: DeviceDataManager) { self.deviceManager = deviceManager + self.sleepStore = SleepStore (healthStore: deviceManager.loopManager.glucoseStore.healthStore) + self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast + self.bedtime = UserDefaults.appGroup?.bedtime self.log = DiagnosticLogger.shared.forCategory("WatchDataManager") super.init() @@ -41,6 +43,53 @@ final class WatchDataManager: NSObject { private var lastSentSettings: LoopSettings? + let sleepStore: SleepStore + + var lastBedtimeQuery: Date { + didSet { + UserDefaults.appGroup?.lastBedtimeQuery = lastBedtimeQuery + } + } + + var bedtime: Date? { + didSet { + UserDefaults.appGroup?.bedtime = bedtime + } + } + + private func updateBedtimeIfNeeded() { + let now = Date() + let lastUpdateInterval = now.timeIntervalSince(lastBedtimeQuery) + let calendar = Calendar.current + + guard lastUpdateInterval >= TimeInterval(hours: 24) else { + // increment the bedtime by 1 day if it's before the current time, but we don't need to make another HealthKit query yet + if let bedtime = bedtime, bedtime < now { + let hourComponent = calendar.component(.hour, from: bedtime) + let minuteComponent = calendar.component(.minute, from: bedtime) + + if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime), newBedtime.timeIntervalSinceNow <= .hours(24) { + self.bedtime = newBedtime + } + } + + return + } + + sleepStore.getAverageSleepStartTime() { + (result) in + + self.lastBedtimeQuery = now + + switch result { + case .success(let bedtime): + self.bedtime = bedtime + case .failure: + self.bedtime = nil + } + } + } + @objc private func updateWatch(_ notification: Notification) { guard let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, @@ -113,12 +162,13 @@ final class WatchDataManager: NSObject { } let complicationShouldUpdate: Bool + updateBedtimeIfNeeded() if let lastContext = lastComplicationContext, let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate, let newGlucose = context.glucose, let newGlucoseDate = context.glucoseDate { - let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate) >= session.complicationUserInfoTransferInterval + let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate) >= session.complicationUserInfoTransferInterval(bedtime: bedtime) let enoughTrendDrift = abs(newGlucose.doubleValue(for: minTrendUnit) - lastGlucose.doubleValue(for: minTrendUnit)) >= minTrendDrift complicationShouldUpdate = enoughTimePassed || enoughTrendDrift @@ -161,6 +211,18 @@ final class WatchDataManager: NSObject { if let trend = self.deviceManager.cgmManager?.sensorState?.trendType { context.glucoseTrendRawValue = trend.rawValue } + + if let glucose = glucose { + updateGroup.enter() + manager.glucoseStore.getCachedGlucoseSamples(start: glucose.startDate) { (samples) in + if let sample = samples.last { + context.glucose = sample.quantity + context.glucoseDate = sample.startDate + context.glucoseSyncIdentifier = sample.syncIdentifier + } + updateGroup.leave() + } + } updateGroup.enter() manager.doseStore.insulinOnBoard(at: Date()) { (result) in @@ -181,7 +243,7 @@ final class WatchDataManager: NSObject { } // Drop the first element in predictedGlucose because it is the current glucose - if let predictedGlucose = state.predictedGlucose?.dropFirst(), predictedGlucose.count > 0 { + if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin?.dropFirst(), predictedGlucose.count > 0 { context.predictedGlucose = WatchPredictedGlucose(values: Array(predictedGlucose)) } @@ -322,6 +384,9 @@ extension WatchDataManager { "## WatchDataManager", "lastSentSettings: \(String(describing: lastSentSettings))", "lastComplicationContext: \(String(describing: lastComplicationContext))", + "lastBedtimeQuery: \(String(describing: lastBedtimeQuery))", + "bedtime: \(String(describing: bedtime))", + "complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: bedtime).minutes ?? 0)) min" ] if let session = watchSession { @@ -334,8 +399,8 @@ extension WatchDataManager { return items.joined(separator: "\n") } -} +} extension WCSession { open override var debugDescription: String { @@ -350,21 +415,29 @@ extension WCSession { "* outstandingUserInfoTransfers: \(outstandingUserInfoTransfers)", "* receivedApplicationContext: \(receivedApplicationContext)", "* remainingComplicationUserInfoTransfers: \(remainingComplicationUserInfoTransfers)", - "* complicationUserInfoTransferInterval: \(round(complicationUserInfoTransferInterval.minutes)) min", "* watchDirectoryURL: \(watchDirectoryURL?.absoluteString ?? "nil")", ].joined(separator: "\n") } - - fileprivate var complicationUserInfoTransferInterval: TimeInterval { + + fileprivate func complicationUserInfoTransferInterval(bedtime: Date?) -> TimeInterval { let now = Date() - let timeUntilMidnight: TimeInterval + let timeUntilRefresh: TimeInterval if let midnight = Calendar.current.nextDate(after: now, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) { - timeUntilMidnight = midnight.timeIntervalSince(now) + // we can have a more frequent refresh rate if we only refresh when it's likely the user is awake (based on HealthKit sleep data) + if let nextBedtime = bedtime { + let timeUntilBedtime = nextBedtime.timeIntervalSince(now) + // if bedtime is before the current time or more than 24 hours away, use midnight instead + timeUntilRefresh = (0.. = [.glucose, .targets] +} - fileprivate enum Rows: Int, CaseCountable { - case notice = 0 - case active +final class BolusViewController: ChartsTableViewController, IdentifiableClass, UITextFieldDelegate { + private enum Row: Int { + case chart = 0 + case carbEntry + case notice case recommended case entry - case deliver } override func viewDidLoad() { super.viewDidLoad() // This gets rid of the empty space at the top. tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.size.width, height: 0.01)) + + glucoseChart.glucoseDisplayRange = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 60)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) + + notificationObservers += [ + NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + DispatchQueue.main.async { + switch LoopDataManager.LoopUpdateContext(rawValue: context) { + case .preferences?: + self?.refreshContext.formUnion([.status, .targets]) + case .glucose?: + self?.refreshContext.update(with: .glucose) + default: + break + } + + self?.reloadData(animated: true) + } + } + ] } override func viewDidAppear(_ animated: Bool) { @@ -37,89 +62,277 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex let amount = bolusRecommendation?.amount ?? 0 bolusAmountTextField.accessibilityHint = String(format: NSLocalizedString("Recommended Bolus: %@ Units", comment: "Accessibility hint describing recommended bolus units"), spellOutFormatter.string(from: amount) ?? "0") + } - bolusAmountTextField.becomeFirstResponder() + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() - AnalyticsManager.shared.didDisplayBolusScreen() - } + // Reposition footer view if necessary + if tableView.contentSize.height != lastContentHeight { + lastContentHeight = tableView.contentSize.height + tableView.tableFooterView = nil - func generateActiveInsulinDescription(activeInsulin: Double?, pendingInsulin: Double?) -> String - { - let iobStr: String - if let iob = activeInsulin, let valueStr = insulinFormatter.string(from: iob) - { - iobStr = valueStr + " U" - } else { - iobStr = "-" + let footerSize = footerView.systemLayoutSizeFitting(CGSize(width: tableView.frame.size.width, height: UIView.layoutFittingCompressedSize.height)) + footerView.frame.size = footerSize + tableView.tableFooterView = footerView } + } - var rval = String(format: NSLocalizedString("Active Insulin: %@", comment: "The string format describing active insulin. (1: localized insulin value description)"), iobStr) + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() - if let pending = pendingInsulin, pending > 0, let pendingStr = insulinFormatter.string(from: pending) - { - rval += String(format: NSLocalizedString(" (pending: %@)", comment: "The string format appended to active insulin that describes pending insulin. (1: pending insulin)"), pendingStr + " U") + if !visible { + refreshContext = RefreshContext.all } - return rval + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + refreshContext.update(with: .size(size)) + + super.viewWillTransition(to: size, with: coordinator) } // MARK: - State + enum Configuration { + case manualCorrection + case newCarbEntry(NewCarbEntry) + case updatedCarbEntry(from: StoredCarbEntry, to: NewCarbEntry) + } + + var configuration: Configuration = .manualCorrection { + didSet { + switch configuration { + case .manualCorrection: + title = NSLocalizedString("Bolus", comment: "Title text for bolus screen (manual correction)") + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)) + case .newCarbEntry, .updatedCarbEntry: + title = NSLocalizedString("Meal Bolus", comment: "Title text for bolus screen following a carb entry") + } + } + } + + var originalCarbEntry: StoredCarbEntry? { + switch configuration { + case .manualCorrection: + return nil + case .newCarbEntry: + return nil + case .updatedCarbEntry(from: let entry, to: _): + return entry + } + } + + private var potentialCarbEntry: NewCarbEntry? { + switch configuration { + case .manualCorrection: + return nil + case .newCarbEntry(let entry): + return entry + case .updatedCarbEntry(from: _, to: let entry): + return entry + } + } + + var selectedDefaultAbsorptionTimeEmoji: String? + var glucoseUnit: HKUnit = .milligramsPerDeciliter + private var computedInitialBolusRecommendation = false + var bolusRecommendation: BolusRecommendation? = nil { didSet { let amount = bolusRecommendation?.amount ?? 0 recommendedBolusAmountLabel?.text = bolusUnitsFormatter.string(from: amount) + updateNotice() - if let pendingInsulin = bolusRecommendation?.pendingInsulin { - self.pendingInsulin = pendingInsulin + let wasNoticeRowHidden = oldValue?.notice == nil + let isNoticeRowHidden = bolusRecommendation?.notice == nil + if wasNoticeRowHidden != isNoticeRowHidden { + tableView.reloadRows(at: [IndexPath(row: Row.notice.rawValue, section: 0)], with: .automatic) + } + + if computedInitialBolusRecommendation, + bolusRecommendation?.amount != oldValue?.amount, + bolusAmountTextField.text?.isEmpty == false + { + bolusAmountTextField.text?.removeAll() + + let alert = UIAlertController( + title: NSLocalizedString("Bolus Recommendation Updated", comment: "Alert title for an updated bolus recommendation"), + message: NSLocalizedString("The bolus recommendation has updated. Please reconfirm the bolus amount.", comment: "Alert message for an updated bolus recommendation"), + preferredStyle: .alert + ) + + let acknowledgeChange = UIAlertAction(title: NSLocalizedString("OK", comment: "Button text to acknowledge an updated bolus recommendation alert"), style: .default) { _ in } + alert.addAction(acknowledgeChange) + + present(alert, animated: true) } } } - var activeCarbohydratesDescription: String? = nil { - didSet { - activeCarbohydratesLabel?.text = activeCarbohydratesDescription + var maxBolus: Double = 25 + + private(set) var bolus: Double? + + private(set) var updatedCarbEntry: NewCarbEntry? + + private var refreshContext = RefreshContext.all + + private let glucoseChart = PredictedGlucoseChart() + + private var chartStartDate: Date { + get { charts.startDate } + set { + if newValue != chartStartDate { + refreshContext = RefreshContext.all + } + + charts.startDate = newValue } } - var activeCarbohydrates: Double? = nil { - didSet { + private var eventualGlucoseDescription: String? - let cobStr: String - if let cob = activeCarbohydrates, let str = integerFormatter.string(from: cob) { - cobStr = str + " g" - } else { - cobStr = "-" + private(set) lazy var footerView: SetupTableFooterView = { + let footerView = SetupTableFooterView(frame: .zero) + footerView.primaryButton.addTarget(self, action: #selector(confirmCarbEntryAndBolus(_:)), for: .touchUpInside) + return footerView + }() + + private var lastContentHeight: CGFloat = 0 + + override func createChartsManager() -> ChartsManager { + ChartsManager(colors: .default, settings: .default, charts: [glucoseChart], traitCollection: traitCollection) + } + + override func glucoseUnitDidChange() { + refreshContext = RefreshContext.all + } + override func reloadData(animated: Bool = false) { + updateChartDateRange() + redrawChart() + + guard active && visible && !refreshContext.isEmpty else { + return + } + + let reloadGroup = DispatchGroup() + if self.refreshContext.remove(.glucose) != nil { + reloadGroup.enter() + self.deviceManager.loopManager.glucoseStore.getCachedGlucoseSamples(start: self.chartStartDate) { (values) -> Void in + self.glucoseChart.setGlucoseValues(values) + reloadGroup.leave() } - activeCarbohydratesDescription = String(format: NSLocalizedString("Active Carbohydrates: %@", comment: "The string format describing active carbohydrates. (1: localized glucose value description)"), cobStr) } - } - var activeInsulinDescription: String? = nil { - didSet { - activeInsulinLabel?.text = activeInsulinDescription + _ = self.refreshContext.remove(.status) + reloadGroup.enter() + self.deviceManager.loopManager.getLoopState { (manager, state) in + let enteredBolus = DispatchQueue.main.sync { self.enteredBolus } + + let predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue] + do { + predictedGlucoseIncludingPendingInsulin = try state.predictGlucose(using: .all, potentialBolus: enteredBolus, potentialCarbEntry: self.potentialCarbEntry, replacingCarbEntry: self.originalCarbEntry, includingPendingInsulin: true) + } catch { + self.refreshContext.update(with: .status) + predictedGlucoseIncludingPendingInsulin = [] + } + + self.glucoseChart.setPredictedGlucoseValues(predictedGlucoseIncludingPendingInsulin) + + if let lastPoint = self.glucoseChart.predictedGlucosePoints.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } + + if self.refreshContext.remove(.targets) != nil { + self.glucoseChart.targetGlucoseSchedule = manager.settings.glucoseTargetRangeSchedule + self.glucoseChart.scheduleOverride = manager.settings.scheduleOverride + } + + if self.glucoseChart.scheduleOverride?.hasFinished() == true { + self.glucoseChart.scheduleOverride = nil + } + + let maximumBolus = manager.settings.maximumBolus + let bolusRecommendation = try? state.recommendBolus(forPrediction: predictedGlucoseIncludingPendingInsulin) + + DispatchQueue.main.async { + if let maxBolus = maximumBolus { + self.maxBolus = maxBolus + } + + self.bolusRecommendation = bolusRecommendation + self.computedInitialBolusRecommendation = true + } + + reloadGroup.leave() } - } - var activeInsulin: Double? = nil { - didSet { - activeInsulinDescription = generateActiveInsulinDescription(activeInsulin: activeInsulin, pendingInsulin: pendingInsulin) + reloadGroup.notify(queue: .main) { + self.updateDeliverButtonState() + self.redrawChart() } } - var pendingInsulin: Double? = nil { - didSet { - activeInsulinDescription = generateActiveInsulinDescription(activeInsulin: activeInsulin, pendingInsulin: pendingInsulin) + private func updateChartDateRange() { + let settings = deviceManager.loopManager.settings + + // How far back should we show data? Use the screen size as a guide. + let availableWidth = (refreshContext.newSize ?? self.tableView.bounds.size).width - self.charts.fixedHorizontalMargin + + let totalHours = floor(Double(availableWidth / settings.minimumChartWidthPerHour)) + let futureHours = ceil((deviceManager.loopManager.insulinModelSettings?.model.effectDuration ?? .hours(4)).hours) + let historyHours = max(settings.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) + + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: historyHours)) + let chartStartDate = Calendar.current.nextDate(after: date, matching: DateComponents(minute: 0), matchingPolicy: .strict, direction: .backward) ?? date + if charts.startDate != chartStartDate { + refreshContext.formUnion(RefreshContext.all) } + charts.startDate = chartStartDate + charts.maxEndDate = chartStartDate.addingTimeInterval(.hours(totalHours)) + charts.updateEndDate(charts.maxEndDate) } + private func redrawChart() { + charts.invalidateChart(atIndex: 0) + charts.prerender() - var maxBolus: Double = 25 + tableView.beginUpdates() + for case let cell as ChartTableViewCell in tableView.visibleCells { + cell.reloadChart() - private(set) var bolus: Double? + if let indexPath = tableView.indexPath(for: cell) { + self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) + } + } + tableView.endUpdates() + } + private var isBolusRecommended: Bool { + bolusRecommendation != nil && bolusRecommendation!.amount > 0 + } + + private func updateDeliverButtonState() { + let deliverText = NSLocalizedString("Deliver", comment: "The button text to initiate a bolus") + if potentialCarbEntry == nil { + footerView.primaryButton.setTitle(deliverText, for: .normal) + footerView.primaryButton.isEnabled = enteredBolusAmount != nil && enteredBolusAmount! > 0 + } else { + if enteredBolusAmount == nil || enteredBolusAmount! == 0 { + footerView.primaryButton.setTitle(NSLocalizedString("Save without Bolusing", comment: "The button text to save a carb entry without bolusing"), for: .normal) + footerView.primaryButton.tintColor = isBolusRecommended ? .alternateBlue : .systemBlue + } else { + footerView.primaryButton.setTitle(deliverText, for: .normal) + footerView.primaryButton.tintColor = .systemBlue + } + } + } // MARK: - IBOutlets @@ -136,49 +349,164 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex } } - @IBOutlet weak var activeCarbohydratesLabel: UILabel? { - didSet { - activeCarbohydratesLabel?.text = activeCarbohydratesDescription - } - } + // MARK: - TableView Delegate - @IBOutlet weak var activeInsulinLabel: UILabel? { - didSet { - activeInsulinLabel?.text = activeInsulinDescription + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + switch Row(rawValue: indexPath.row)! { + case .carbEntry where potentialCarbEntry == nil: + return 0 + case .notice where bolusRecommendation?.notice == nil: + return 0 + default: + return super.tableView(tableView, heightForRowAt: indexPath) } } - // MARK: - TableView Delegate - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if case .recommended? = Rows(rawValue: indexPath.row) { + switch Row(rawValue: indexPath.row)! { + case .carbEntry where potentialCarbEntry != nil: + navigationController?.popViewController(animated: true) + case .recommended: acceptRecommendedBolus() + default: + break } } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if case .recommended? = Rows(rawValue: indexPath.row) { + let row = Row(rawValue: indexPath.row) + switch row { + case .carbEntry: + guard let entry = potentialCarbEntry else { + return + } + + let cell = cell as! PotentialCarbEntryTableViewCell + let unit = HKUnit.gram() + let carbText = carbFormatter.string(from: entry.quantity.doubleValue(for: unit), unit: unit.unitString) + + if let carbText = carbText, let foodType = entry.foodType ?? selectedDefaultAbsorptionTimeEmoji { + cell.valueLabel?.text = String( + format: NSLocalizedString("%1$@: %2$@", comment: "Formats (1: carb value) and (2: food type)"), + carbText, foodType + ) + } else { + cell.valueLabel?.text = carbText + } + + let startTime = timeFormatter.string(from: entry.startDate) + if let absorptionTime = entry.absorptionTime, + let duration = absorptionFormatter.string(from: absorptionTime) + { + cell.dateLabel?.text = String( + format: NSLocalizedString("%1$@ + %2$@", comment: "Formats (1: carb start time) and (2: carb absorption duration)"), + startTime, duration + ) + } else { + cell.dateLabel?.text = startTime + } + case .chart: + let cell = cell as! ChartTableViewCell + cell.contentView.layoutMargins.left = tableView.separatorInset.left + cell.chartContentView.chartGenerator = { [weak self] (frame) in + return self?.charts.chart(atIndex: 0, frame: frame)?.view + } + + cell.titleLabel?.text?.removeAll() + cell.subtitleLabel?.textColor = UIColor.secondaryLabelColor + self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) + cell.selectionStyle = .none + + cell.addGestureRecognizer(charts.gestureRecognizer!) + case .recommended: cell.accessibilityCustomActions = [ UIAccessibilityCustomAction(name: NSLocalizedString("AcceptRecommendedBolus", comment: "Action to copy the recommended Bolus value to the actual Bolus Field"), target: self, selector: #selector(BolusViewController.acceptRecommendedBolus)) ] + default: + break + } + } + + private func tableView(_ tableView: UITableView, updateSubtitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { + assert(Row(rawValue: indexPath.row) == .chart) + + if let eventualGlucose = eventualGlucoseDescription { + cell.subtitleLabel?.text = String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose) + } else { + cell.subtitleLabel?.text?.removeAll() } } - @objc - func acceptRecommendedBolus() { + @objc func acceptRecommendedBolus() { bolusAmountTextField?.text = recommendedBolusAmountLabel?.text + bolusAmountTextField?.resignFirstResponder() + + updateDeliverButtonState() + predictionRecomputation?.cancel() + recomputePrediction() } - - @IBOutlet weak var bolusAmountTextField: UITextField! + @IBOutlet weak var bolusAmountTextField: UITextField! { + didSet { + bolusAmountTextField.addTarget(self, action: #selector(bolusAmountChanged), for: .editingChanged) + } + } + + private var enteredBolusAmount: Double? { + guard let text = bolusAmountTextField?.text, let amount = bolusUnitsFormatter.number(from: text)?.doubleValue else { + return nil + } + + return amount >= 0 ? amount : nil + } + + private var enteredBolus: DoseEntry? { + guard let amount = enteredBolusAmount else { + return nil + } + + return DoseEntry(type: .bolus, startDate: Date(), value: amount, unit: .units) + } + + private var predictionRecomputation: DispatchWorkItem? + + @objc private func bolusAmountChanged() { + updateDeliverButtonState() + + predictionRecomputation?.cancel() + let predictionRecomputation = DispatchWorkItem(block: recomputePrediction) + self.predictionRecomputation = predictionRecomputation + let recomputeDelayMS = 300 + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(recomputeDelayMS), execute: predictionRecomputation) + } + + private func recomputePrediction() { + deviceManager.loopManager.getLoopState { [weak self] manager, state in + guard let self = self else { return } + let enteredBolus = DispatchQueue.main.sync { self.enteredBolus } + if let prediction = try? state.predictGlucose(using: .all, potentialBolus: enteredBolus, potentialCarbEntry: self.potentialCarbEntry, replacingCarbEntry: self.originalCarbEntry, includingPendingInsulin: true) { + self.glucoseChart.setPredictedGlucoseValues(prediction) + + if let lastPoint = self.glucoseChart.predictedGlucosePoints.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } + + DispatchQueue.main.async { + self.redrawChart() + } + } + } + } // MARK: - Actions - @IBAction func authenticateBolus(_ sender: Any) { + @objc private func confirmCarbEntryAndBolus(_ sender: Any) { bolusAmountTextField.resignFirstResponder() - guard let text = bolusAmountTextField?.text, let bolus = bolusUnitsFormatter.number(from: text)?.doubleValue, - let amountString = bolusUnitsFormatter.string(from: bolus) else { + guard let bolus = enteredBolusAmount, let amountString = bolusUnitsFormatter.string(from: bolus) else { + setBolusAndClose(0) return } @@ -214,36 +542,43 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex } private func setBolusAndClose(_ bolus: Double) { + self.updatedCarbEntry = potentialCarbEntry self.bolus = bolus self.performSegue(withIdentifier: "close", sender: nil) } - private lazy var bolusUnitsFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - - numberFormatter.maximumSignificantDigits = 3 - numberFormatter.minimumFractionDigits = 1 + @objc private func cancel() { + dismiss(animated: true) + } - return numberFormatter + private lazy var carbFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + return formatter }() + private lazy var absorptionFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.collapsesLargestUnit = true + formatter.unitsStyle = .abbreviated + formatter.allowsFractionalUnits = true + formatter.allowedUnits = [.hour, .minute] + return formatter + }() - private lazy var insulinFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - - numberFormatter.numberStyle = .decimal - numberFormatter.minimumFractionDigits = 2 - numberFormatter.maximumFractionDigits = 2 - - return numberFormatter + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter }() - private lazy var integerFormatter: NumberFormatter = { + private lazy var bolusUnitsFormatter: NumberFormatter = { let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .none - numberFormatter.maximumFractionDigits = 0 + numberFormatter.maximumSignificantDigits = 3 + numberFormatter.minimumFractionDigits = 1 return numberFormatter }() @@ -270,3 +605,25 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex return true } } + +extension BolusViewController { + static func instance() -> BolusViewController { + let storyboard = UIStoryboard(name: "Main", bundle: nil) + return storyboard.instantiateViewController(withIdentifier: className) as! BolusViewController + } +} + + +extension UIColor { + static var alternateBlue: UIColor { + if #available(iOS 13.0, *) { + return UIColor(dynamicProvider: { traitCollection in + traitCollection.userInterfaceStyle == .dark + ? UIColor(red: 50 / 255, green: 148 / 255, blue: 255 / 255, alpha: 1.0) + : UIColor(red: 0 / 255, green: 97 / 255, blue: 204 / 255, alpha: 1.0) + }) + } else { + return UIColor(red: 50 / 255, green: 148 / 255, blue: 255 / 255, alpha: 1.0) + } + } +} diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 9a1a67556e..b65e8fcaf6 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -159,7 +159,7 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl reloadGroup.enter() manager.carbStore.getGlucoseEffects(start: chartStartDate, effectVelocities: manager.settings.dynamicCarbAbsorptionEnabled ? insulinCounteractionEffects : nil) { (result) in switch result { - case .success(let effects): + case .success(let (_, effects)): carbEffects = effects case .failure(let error): carbEffects = [] @@ -479,7 +479,7 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl override func restoreUserActivityState(_ activity: NSUserActivity) { switch activity.activityType { case NSUserActivity.newCarbEntryActivityType: - performSegue(withIdentifier: CarbEntryEditViewController.className, sender: activity) + performSegue(withIdentifier: CarbEntryViewController.className, sender: activity) default: break } @@ -495,18 +495,16 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl } switch targetViewController { - case let vc as BolusViewController: - vc.configureWithLoopManager(self.deviceManager.loopManager, - recommendation: sender as? BolusRecommendation, - glucoseUnit: self.carbEffectChart.glucoseUnit - ) - case let vc as CarbEntryEditViewController: + case is BolusViewController: + assertionFailure() + case let vc as CarbEntryViewController: if let selectedCell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: selectedCell), indexPath.row < carbStatuses.count { vc.originalCarbEntry = carbStatuses[indexPath.row].entry } else if let activity = sender as? NSUserActivity { vc.restoreUserActivityState(activity) } + vc.deviceManager = deviceManager vc.defaultAbsorptionTimes = deviceManager.loopManager.carbStore.defaultAbsorptionTimes vc.preferredUnit = deviceManager.loopManager.carbStore.preferredUnit default: @@ -514,48 +512,41 @@ final class CarbAbsorptionViewController: ChartsTableViewController, Identifiabl } } - /// Unwind segue action from the CarbEntryEditViewController - /// - /// - parameter segue: The unwind segue - @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { - guard let editVC = segue.source as? CarbEntryEditViewController, - let updatedEntry = editVC.updatedCarbEntry - else { + @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) {} + + @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { + guard let bolusViewController = segue.source as? BolusViewController else { return } - if #available(iOS 12.0, *), editVC.originalCarbEntry == nil { - let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) - interaction.donate { (error) in - if let error = error { - os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) - } - } - } - deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry, replacing: editVC.originalCarbEntry) { (result) in - DispatchQueue.main.async { - switch result { - case .success(let recommendation): - if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { - self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) - } - case .failure(let error): - // Ignore bolus wizard errors - if error is CarbStore.CarbStoreError { - self.present(UIAlertController(with: error), animated: true) + if let updatedEntry = bolusViewController.updatedCarbEntry { + if #available(iOS 12.0, *), bolusViewController.originalCarbEntry == nil { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + interaction.donate { (error) in + if let error = error { + os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) } } } - } - } - - @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { - if let bolusViewController = segue.source as? BolusViewController { - if let bolus = bolusViewController.bolus, bolus > 0 { - deviceManager.enactBolus(units: bolus) { (_) in + + deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry, replacing: bolusViewController.originalCarbEntry) { (result) in + DispatchQueue.main.async { + switch result { + case .success: + // Enact the user-entered bolus + if let bolus = bolusViewController.bolus, bolus > 0 { + self.deviceManager.enactBolus(units: bolus) { _ in } + } + case .failure(let error): + // Ignore bolus wizard errors + if error is CarbStore.CarbStoreError { + self.present(UIAlertController(with: error), animated: true) + } + } } } + } else if let bolus = bolusViewController.bolus, bolus > 0 { + deviceManager.enactBolus(units: bolus) { _ in } } } - } diff --git a/Loop/View Controllers/CarbEntryEditTableViewController.swift b/Loop/View Controllers/CarbEntryEditTableViewController.swift deleted file mode 100644 index 99f73c6102..0000000000 --- a/Loop/View Controllers/CarbEntryEditTableViewController.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// CarbEntryEditTableViewController.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/25/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import LoopKitUI -import LoopCore - - -extension CarbEntryEditViewController: IdentifiableClass { -} diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift new file mode 100644 index 0000000000..44801929fc --- /dev/null +++ b/Loop/View Controllers/CarbEntryViewController.swift @@ -0,0 +1,501 @@ +// +// CarbEntryViewController.swift +// CarbKit +// +// Created by Nathan Racklyeft on 1/15/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import UIKit +import HealthKit +import LoopKit +import LoopKitUI +import LoopCore +import LoopUI + + +final class CarbEntryViewController: ChartsTableViewController, IdentifiableClass { + + var navigationDelegate = CarbEntryNavigationDelegate() + + var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes? { + didSet { + if let times = defaultAbsorptionTimes { + orderedAbsorptionTimes = [times.fast, times.medium, times.slow] + } + } + } + + fileprivate var orderedAbsorptionTimes = [TimeInterval]() + + var preferredUnit = HKUnit.gram() + + var maxQuantity = HKQuantity(unit: .gram(), doubleValue: 250) + + /// Entry configuration values. Must be set before presenting. + var absorptionTimePickerInterval = TimeInterval(minutes: 30) + + var maxAbsorptionTime = TimeInterval(hours: 8) + + var maximumDateFutureInterval = TimeInterval(hours: 4) + + var glucoseUnit: HKUnit = .milligramsPerDeciliter + + var originalCarbEntry: StoredCarbEntry? { + didSet { + if let entry = originalCarbEntry { + quantity = entry.quantity + date = entry.startDate + foodType = entry.foodType + absorptionTime = entry.absorptionTime + + absorptionTimeWasEdited = true + usesCustomFoodType = true + + shouldBeginEditingQuantity = false + } + } + } + + fileprivate var quantity: HKQuantity? { + didSet { + updateContinueButtonEnabled() + } + } + + fileprivate var date = Date() { + didSet { + updateContinueButtonEnabled() + } + } + + fileprivate var foodType: String? { + didSet { + updateContinueButtonEnabled() + } + } + + fileprivate var absorptionTime: TimeInterval? { + didSet { + updateContinueButtonEnabled() + } + } + + private var selectedDefaultAbsorptionTimeEmoji: String? + + fileprivate var absorptionTimeWasEdited = false + + fileprivate var usesCustomFoodType = false + + private var shouldBeginEditingQuantity = true + + private var shouldBeginEditingFoodType = false + + var updatedCarbEntry: NewCarbEntry? { + if let quantity = quantity, + let absorptionTime = absorptionTime ?? defaultAbsorptionTimes?.medium + { + if let o = originalCarbEntry, o.quantity == quantity && o.startDate == date && o.foodType == foodType && o.absorptionTime == absorptionTime { + return nil // No changes were made + } + + return NewCarbEntry( + quantity: quantity, + startDate: date, + foodType: foodType, + absorptionTime: absorptionTime, + externalID: originalCarbEntry?.externalID + ) + } else { + return nil + } + } + + private var isSampleEditable: Bool { + return originalCarbEntry?.createdByCurrentApp != false + } + + private(set) lazy var footerView: SetupTableFooterView = { + let footerView = SetupTableFooterView(frame: .zero) + footerView.primaryButton.addTarget(self, action: #selector(continueButtonPressed), for: .touchUpInside) + footerView.primaryButton.isEnabled = quantity != nil && quantity!.doubleValue(for: preferredUnit) > 0 + return footerView + }() + + private var lastContentHeight: CGFloat = 0 + + override func createChartsManager() -> ChartsManager { + // Consider including a chart on this screen to demonstrate how absorption time affects prediction + ChartsManager(colors: .default, settings: .default, charts: [], traitCollection: traitCollection) + } + + override func glucoseUnitDidChange() { + // Consider including a chart on this screen to demonstrate how absorption time affects prediction + } + + override func viewDidLoad() { + super.viewDidLoad() + + // This gets rid of the empty space at the top. + tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.size.width, height: 0.01)) + + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 44 + tableView.register(DateAndDurationTableViewCell.nib(), forCellReuseIdentifier: DateAndDurationTableViewCell.className) + + if originalCarbEntry != nil { + title = NSLocalizedString("carb-entry-title-edit", value: "Edit Carb Entry", comment: "The title of the view controller to edit an existing carb entry") + } else { + title = NSLocalizedString("carb-entry-title-add", value: "Add Carb Entry", comment: "The title of the view controller to create a new carb entry") + } + + navigationItem.rightBarButtonItem = UIBarButtonItem(title: footerView.primaryButton.titleLabel?.text, style: .plain, target: self, action: #selector(continueButtonPressed)) + navigationItem.rightBarButtonItem?.isEnabled = false + + // Sets text for back button on bolus screen + navigationItem.backBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Carb Entry", comment: "Back button text for bolus screen to return to carb entry screen"), style: .plain, target: nil, action: nil) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if shouldBeginEditingQuantity, let cell = tableView.cellForRow(at: IndexPath(row: Row.value.rawValue, section: 0)) as? DecimalTextFieldTableViewCell { + shouldBeginEditingQuantity = false + cell.textField.becomeFirstResponder() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Reposition footer view if necessary + if tableView.contentSize.height != lastContentHeight { + lastContentHeight = tableView.contentSize.height + tableView.tableFooterView = nil + + let footerSize = footerView.systemLayoutSizeFitting(CGSize(width: tableView.frame.size.width, height: UIView.layoutFittingCompressedSize.height)) + footerView.frame.size = footerSize + tableView.tableFooterView = footerView + } + } + + private var foodKeyboard: EmojiInputController! + + // MARK: - Table view data source + + fileprivate enum Row: Int { + case value + case date + case foodType + case absorptionTime + + static let count = 4 + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return Row.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Row(rawValue: indexPath.row)! { + case .value: + let cell = tableView.dequeueReusableCell(withIdentifier: DecimalTextFieldTableViewCell.className) as! DecimalTextFieldTableViewCell + + if let quantity = quantity { + cell.number = NSNumber(value: quantity.doubleValue(for: preferredUnit)) + } + cell.textField.isEnabled = isSampleEditable + cell.unitLabel?.text = String(describing: preferredUnit) + cell.delegate = self + + return cell + case .date: + let cell = tableView.dequeueReusableCell(withIdentifier: DateAndDurationTableViewCell.className) as! DateAndDurationTableViewCell + + cell.titleLabel.text = NSLocalizedString("Date", comment: "Title of the carb entry date picker cell") + cell.datePicker.isEnabled = isSampleEditable + cell.datePicker.datePickerMode = .dateAndTime + cell.datePicker.maximumDate = Date(timeIntervalSinceNow: maximumDateFutureInterval) + cell.datePicker.minuteInterval = 1 + cell.date = date + cell.delegate = self + + return cell + case .foodType: + if usesCustomFoodType { + let cell = tableView.dequeueReusableCell(withIdentifier: TextFieldTableViewCell.className, for: indexPath) as! TextFieldTableViewCell + + cell.textField.text = foodType + cell.delegate = self + + if let textField = cell.textField as? CustomInputTextField { + if foodKeyboard == nil { + foodKeyboard = CarbAbsorptionInputController() + foodKeyboard.delegate = self + } + + textField.customInput = foodKeyboard + } + + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: FoodTypeShortcutCell.className, for: indexPath) as! FoodTypeShortcutCell + + if absorptionTime == nil { + cell.selectionState = .medium + } + + selectedDefaultAbsorptionTimeEmoji = cell.selectedEmoji + cell.delegate = self + + return cell + } + case .absorptionTime: + let cell = tableView.dequeueReusableCell(withIdentifier: DateAndDurationTableViewCell.className) as! DateAndDurationTableViewCell + + cell.titleLabel.text = NSLocalizedString("Absorption Time", comment: "Title of the carb entry absorption time cell") + cell.datePicker.isEnabled = isSampleEditable + cell.datePicker.datePickerMode = .countDownTimer + cell.datePicker.minuteInterval = Int(absorptionTimePickerInterval.minutes) + + if let duration = absorptionTime ?? defaultAbsorptionTimes?.medium { + cell.duration = duration + } + + cell.maximumDuration = maxAbsorptionTime + cell.delegate = self + + return cell + } + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + switch Row(rawValue: indexPath.row)! { + case .value, .date: + break + case .foodType: + if usesCustomFoodType, shouldBeginEditingFoodType, let cell = cell as? TextFieldTableViewCell { + shouldBeginEditingFoodType = false + cell.textField.becomeFirstResponder() + } + case .absorptionTime: + break + } + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return NSLocalizedString("Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact.", comment: "Carb entry section footer text explaining absorption time") + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + tableView.endEditing(false) + tableView.beginUpdates() + hideDatePickerCells(excluding: indexPath) + return indexPath + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch tableView.cellForRow(at: indexPath) { + case is FoodTypeShortcutCell: + usesCustomFoodType = true + shouldBeginEditingFoodType = true + tableView.reloadRows(at: [IndexPath(row: Row.foodType.rawValue, section: 0)], with: .none) + default: + break + } + + tableView.endUpdates() + tableView.deselectRow(at: indexPath, animated: true) + } + + // MARK: - Navigation + + override func restoreUserActivityState(_ activity: NSUserActivity) { + if let entry = activity.newCarbEntry { + quantity = entry.quantity + date = entry.startDate + + if let foodType = entry.foodType { + self.foodType = foodType + usesCustomFoodType = true + } + + if let absorptionTime = entry.absorptionTime { + self.absorptionTime = absorptionTime + absorptionTimeWasEdited = true + } + } + } + + @objc private func continueButtonPressed() { + tableView.endEditing(true) + guard validateInput(), let updatedEntry = updatedCarbEntry else { + return + } + + let bolusVC = BolusViewController.instance() + bolusVC.deviceManager = deviceManager + bolusVC.glucoseUnit = glucoseUnit + if let originalEntry = originalCarbEntry { + bolusVC.configuration = .updatedCarbEntry(from: originalEntry, to: updatedEntry) + } else { + bolusVC.configuration = .newCarbEntry(updatedEntry) + } + bolusVC.selectedDefaultAbsorptionTimeEmoji = selectedDefaultAbsorptionTimeEmoji + + show(bolusVC, sender: footerView.primaryButton) + } + + private func validateInput() -> Bool { + guard let absorptionTime = absorptionTime ?? defaultAbsorptionTimes?.medium else { + return false + } + guard absorptionTime <= maxAbsorptionTime else { + navigationDelegate.showAbsorptionTimeValidationWarning(for: self, maxAbsorptionTime: maxAbsorptionTime) + return false + } + + guard let quantity = quantity, quantity.doubleValue(for: preferredUnit) > 0 else { return false } + guard quantity.compare(maxQuantity) != .orderedDescending else { + navigationDelegate.showMaxQuantityValidationWarning(for: self, maxQuantityGrams: maxQuantity.doubleValue(for: .gram())) + return false + } + + return true + } + + private func updateContinueButtonEnabled() { + let hasValidQuantity = quantity != nil && quantity!.doubleValue(for: preferredUnit) > 0 + let haveChangesBeenMade = updatedCarbEntry != nil + + let readyToContinue = hasValidQuantity && haveChangesBeenMade + + footerView.primaryButton.isEnabled = readyToContinue + navigationItem.rightBarButtonItem?.isEnabled = readyToContinue + } +} + + +extension CarbEntryViewController: TextFieldTableViewCellDelegate { + func textFieldTableViewCellDidBeginEditing(_ cell: TextFieldTableViewCell) { + // Collapse any date picker cells to save space + tableView.beginUpdates() + hideDatePickerCells() + tableView.endUpdates() + } + + func textFieldTableViewCellDidEndEditing(_ cell: TextFieldTableViewCell) { + guard let row = tableView.indexPath(for: cell)?.row else { return } + + switch Row(rawValue: row) { + case .value?: + if let cell = cell as? DecimalTextFieldTableViewCell, let number = cell.number { + quantity = HKQuantity(unit: preferredUnit, doubleValue: number.doubleValue) + } else { + quantity = nil + } + case .foodType?: + foodType = cell.textField.text + default: + break + } + } + + func textFieldTableViewCellDidChangeEditing(_ cell: TextFieldTableViewCell) { + guard let row = tableView.indexPath(for: cell)?.row else { return } + + switch Row(rawValue: row) { + case .value?: + if let cell = cell as? DecimalTextFieldTableViewCell, let number = cell.number { + quantity = HKQuantity(unit: preferredUnit, doubleValue: number.doubleValue) + } else { + quantity = nil + } + default: + break + } + } +} + + +extension CarbEntryViewController: DatePickerTableViewCellDelegate { + func datePickerTableViewCellDidUpdateDate(_ cell: DatePickerTableViewCell) { + guard let row = tableView.indexPath(for: cell)?.row else { return } + + switch Row(rawValue: row) { + case .date?: + date = cell.date + case .absorptionTime?: + absorptionTime = cell.duration + absorptionTimeWasEdited = true + default: + break + } + } +} + + +extension CarbEntryViewController: FoodTypeShortcutCellDelegate { + func foodTypeShortcutCellDidUpdateSelection(_ cell: FoodTypeShortcutCell) { + var absorptionTime: TimeInterval? + + switch cell.selectionState { + case .fast: + absorptionTime = defaultAbsorptionTimes?.fast + case .medium: + absorptionTime = defaultAbsorptionTimes?.medium + case .slow: + absorptionTime = defaultAbsorptionTimes?.slow + case .custom: + tableView.beginUpdates() + usesCustomFoodType = true + shouldBeginEditingFoodType = true + tableView.reloadRows(at: [IndexPath(row: Row.foodType.rawValue, section: 0)], with: .fade) + tableView.endUpdates() + } + + if let absorptionTime = absorptionTime { + self.absorptionTime = absorptionTime + + if let cell = tableView.cellForRow(at: IndexPath(row: Row.absorptionTime.rawValue, section: 0)) as? DateAndDurationTableViewCell { + cell.duration = absorptionTime + } + } + + selectedDefaultAbsorptionTimeEmoji = cell.selectedEmoji + } +} + + +extension CarbEntryViewController: EmojiInputControllerDelegate { + func emojiInputControllerDidAdvanceToStandardInputMode(_ controller: EmojiInputController) { + if let cell = tableView.cellForRow(at: IndexPath(row: Row.foodType.rawValue, section: 0)) as? TextFieldTableViewCell, let textField = cell.textField as? CustomInputTextField, textField.customInput != nil { + let customInput = textField.customInput + textField.customInput = nil + textField.resignFirstResponder() + textField.becomeFirstResponder() + textField.customInput = customInput + } + } + + func emojiInputControllerDidSelectItemInSection(_ section: Int) { + guard !absorptionTimeWasEdited, section < orderedAbsorptionTimes.count else { + return + } + + let lastAbsorptionTime = self.absorptionTime + self.absorptionTime = orderedAbsorptionTimes[section] + + if let cell = tableView.cellForRow(at: IndexPath(row: Row.absorptionTime.rawValue, section: 0)) as? DateAndDurationTableViewCell { + cell.duration = max(lastAbsorptionTime ?? 0, orderedAbsorptionTimes[section]) + } + } +} + +extension DateAndDurationTableViewCell: NibLoadable {} diff --git a/Loop/View Controllers/CommandResponseViewController.swift b/Loop/View Controllers/CommandResponseViewController.swift index f6d9d3ac25..d000dfe99d 100644 --- a/Loop/View Controllers/CommandResponseViewController.swift +++ b/Loop/View Controllers/CommandResponseViewController.swift @@ -16,14 +16,12 @@ extension CommandResponseViewController { static func generateDiagnosticReport(deviceManager: DeviceDataManager) -> T { let date = Date() let vc = T(command: { (completionHandler) in - deviceManager.loopManager.generateDiagnosticReport { (report) in + deviceManager.generateDiagnosticReport { (report) in DispatchQueue.main.async { completionHandler([ "Use the Share button above save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", "Generated: \(date)", "", - String(reflecting: deviceManager), - "", report, "", ].joined(separator: "\n\n")) diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 62b8183e6e..ddfa4eac92 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -123,10 +123,10 @@ class PredictionTableViewController: ChartsTableViewController, IdentifiableClas self.deviceManager.loopManager.getLoopState { (manager, state) in self.retrospectiveGlucoseDiscrepancies = state.retrospectiveGlucoseDiscrepancies totalRetrospectiveCorrection = state.totalRetrospectiveCorrection - self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucose ?? []) + self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? []) do { - let glucose = try state.predictGlucose(using: self.selectedInputs) + let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true) self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) } catch { self.refreshContext.update(with: .status) diff --git a/Loop/View Controllers/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift index 4529831074..c6f763e25a 100644 --- a/Loop/View Controllers/RootNavigationController.swift +++ b/Loop/View Controllers/RootNavigationController.swift @@ -29,7 +29,7 @@ class RootNavigationController: UINavigationController { } case NSUserActivity.newCarbEntryActivityType: if let navVC = presentedViewController as? UINavigationController { - if let carbVC = navVC.topViewController as? CarbEntryEditViewController { + if let carbVC = navVC.topViewController as? CarbEntryViewController { carbVC.restoreUserActivityState(activity) return } else { diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index e806e7776b..d7b6c4bd4e 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -521,6 +521,16 @@ final class SettingsTableViewController: UITableViewController { case .basalRate: guard let pumpManager = dataManager.pumpManager else { // Not allowing basal schedule entry without a configured pump. + let alert = UIAlertController( + title: NSLocalizedString("Unconfigured Pump", comment: "Alert title for unconfigured pump"), + message: NSLocalizedString("Please configure a pump to view or edit scheduled basal rates.", comment: "Alert message for attempting to change basal rates before pump was configured."), + preferredStyle: .alert + ) + + let acknowledgeChange = UIAlertAction(title: NSLocalizedString("OK", comment: "Button text to dismiss unconfigured pump alert."), style: .default) { _ in } + alert.addAction(acknowledgeChange) + + present(alert, animated: true) tableView.deselectRow(at: indexPath, animated: true) return } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9e7f37c6f2..50fe1c9fb8 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -313,7 +313,7 @@ final class StatusTableViewController: ChartsTableViewController { // TODO: Don't always assume currentContext.contains(.status) reloadGroup.enter() self.deviceManager.loopManager.getLoopState { (manager, state) -> Void in - predictedGlucoseValues = state.predictedGlucose ?? [] + predictedGlucoseValues = state.predictedGlucoseIncludingPendingInsulin ?? [] // Retry this refresh again if predicted glucose isn't available if state.predictedGlucose == nil { @@ -353,8 +353,10 @@ final class StatusTableViewController: ChartsTableViewController { if currentContext.contains(.carbs) { reloadGroup.enter() manager.carbStore.getCarbsOnBoardValues(start: startDate, effectVelocities: manager.settings.dynamicCarbAbsorptionEnabled ? state.insulinCounteractionEffects : nil) { (values) in - cobValues = values - reloadGroup.leave() + DispatchQueue.main.async { + cobValues = values + reloadGroup.leave() + } } } @@ -364,49 +366,57 @@ final class StatusTableViewController: ChartsTableViewController { if currentContext.contains(.glucose) { reloadGroup.enter() self.deviceManager.loopManager.glucoseStore.getCachedGlucoseSamples(start: startDate) { (values) -> Void in - glucoseValues = values - reloadGroup.leave() + DispatchQueue.main.async { + glucoseValues = values + reloadGroup.leave() + } } } if currentContext.contains(.insulin) { reloadGroup.enter() deviceManager.loopManager.doseStore.getInsulinOnBoardValues(start: startDate) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get insulin on board values: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - iobValues = [] - case .success(let values): - iobValues = values + DispatchQueue.main.async { + switch result { + case .failure(let error): + self.log.error("DoseStore failed to get insulin on board values: %{public}@", String(describing: error)) + retryContext.update(with: .insulin) + iobValues = [] + case .success(let values): + iobValues = values + } + reloadGroup.leave() } - reloadGroup.leave() } reloadGroup.enter() deviceManager.loopManager.doseStore.getNormalizedDoseEntries(start: startDate) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get normalized dose entries: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - doseEntries = [] - case .success(let doses): - doseEntries = doses + DispatchQueue.main.async { + switch result { + case .failure(let error): + self.log.error("DoseStore failed to get normalized dose entries: %{public}@", String(describing: error)) + retryContext.update(with: .insulin) + doseEntries = [] + case .success(let doses): + doseEntries = doses + } + reloadGroup.leave() } - reloadGroup.leave() } reloadGroup.enter() deviceManager.loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())) { (result) in - switch result { - case .failure: - retryContext.update(with: .insulin) - totalDelivery = nil - case .success(let total): - totalDelivery = total.value - } + DispatchQueue.main.async { + switch result { + case .failure: + retryContext.update(with: .insulin) + totalDelivery = nil + case .success(let total): + totalDelivery = total.value + } - reloadGroup.leave() + reloadGroup.leave() + } } } @@ -487,6 +497,7 @@ final class StatusTableViewController: ChartsTableViewController { hudView.glucoseHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), at: glucose.startDate, unit: unit, + staleGlucoseAge: self.deviceManager.loopManager.settings.inputDataRecencyInterval, sensor: self.deviceManager.sensorState ) } @@ -1045,7 +1056,7 @@ final class StatusTableViewController: ChartsTableViewController { override func restoreUserActivityState(_ activity: NSUserActivity) { switch activity.activityType { case NSUserActivity.newCarbEntryActivityType: - performSegue(withIdentifier: CarbEntryEditViewController.className, sender: activity) + performSegue(withIdentifier: CarbEntryViewController.className, sender: activity) default: break } @@ -1064,10 +1075,9 @@ final class StatusTableViewController: ChartsTableViewController { case let vc as CarbAbsorptionViewController: vc.deviceManager = deviceManager vc.hidesBottomBarWhenPushed = true - case let vc as CarbEntryTableViewController: - vc.carbStore = deviceManager.loopManager.carbStore - vc.hidesBottomBarWhenPushed = true - case let vc as CarbEntryEditViewController: + case let vc as CarbEntryViewController: + vc.deviceManager = deviceManager + vc.glucoseUnit = statusCharts.glucose.glucoseUnit vc.defaultAbsorptionTimes = deviceManager.loopManager.carbStore.defaultAbsorptionTimes vc.preferredUnit = deviceManager.loopManager.carbStore.preferredUnit @@ -1078,10 +1088,10 @@ final class StatusTableViewController: ChartsTableViewController { vc.doseStore = deviceManager.loopManager.doseStore vc.hidesBottomBarWhenPushed = true case let vc as BolusViewController: - vc.configureWithLoopManager(self.deviceManager.loopManager, - recommendation: sender as? BolusRecommendation, - glucoseUnit: self.statusCharts.glucose.glucoseUnit - ) + vc.deviceManager = deviceManager + vc.glucoseUnit = statusCharts.glucose.glucoseUnit + vc.configuration = .manualCorrection + AnalyticsManager.shared.didDisplayBolusScreen() case let vc as OverrideSelectionViewController: if deviceManager.loopManager.settings.futureOverrideEnabled() { vc.scheduledOverride = deviceManager.loopManager.settings.scheduleOverride @@ -1098,46 +1108,43 @@ final class StatusTableViewController: ChartsTableViewController { } } - /// Unwind segue action from the CarbEntryEditViewController - /// - /// - parameter segue: The unwind segue - @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { - guard let carbVC = segue.source as? CarbEntryEditViewController, let updatedEntry = carbVC.updatedCarbEntry else { + @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) {} + + @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { + guard let bolusViewController = segue.source as? BolusViewController else { return } - if #available(iOS 12.0, *) { - let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) - interaction.donate { [weak self] (error) in - if let error = error { - self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) - } - } - } - deviceManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (result) -> Void in - DispatchQueue.main.async { - switch result { - case .success(let recommendation): - if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { - self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) - } - case .failure(let error): - // Ignore bolus wizard errors - if error is CarbStore.CarbStoreError { - self.present(UIAlertController(with: error), animated: true) - } else { - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + if let carbEntry = bolusViewController.updatedCarbEntry { + if #available(iOS 12.0, *) { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + interaction.donate { [weak self] (error) in + if let error = error { + self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) } } } - } - } - @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { - if let bolusViewController = segue.source as? BolusViewController { - if let bolus = bolusViewController.bolus, bolus > 0 { - deviceManager.enactBolus(units: bolus) { (_) in } + deviceManager.loopManager.addCarbEntryAndRecommendBolus(carbEntry) { result in + DispatchQueue.main.async { + switch result { + case .success: + // Enact the user-entered bolus + if let bolus = bolusViewController.bolus, bolus > 0 { + self.deviceManager.enactBolus(units: bolus) { _ in } + } + case .failure(let error): + // Ignore bolus wizard errors + if error is CarbStore.CarbStoreError { + self.present(UIAlertController(with: error), animated: true) + } else { + self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + } + } + } } + } else if let bolus = bolusViewController.bolus, bolus > 0 { + self.deviceManager.enactBolus(units: bolus) { _ in } } } @@ -1262,13 +1269,23 @@ final class StatusTableViewController: ChartsTableViewController { } @objc private func showLastError(_: Any) { + var error: Error? = nil // First, check whether we have a device error after the most recent completion date if let deviceError = deviceManager.lastError, deviceError.date > (hudView?.loopCompletionHUD.lastLoopCompleted ?? .distantPast) { - self.present(UIAlertController(with: deviceError.error), animated: true) + error = deviceError.error } else if let lastLoopError = lastLoopError { - self.present(UIAlertController(with: lastLoopError), animated: true) + error = lastLoopError + } + + if error != nil { + let alertController = UIAlertController(with: error!) + let manualLoopAction = UIAlertAction(title: NSLocalizedString("Retry", comment: "The button text for attempting a manual loop"), style: .default, handler: { _ in + self.deviceManager.loopManager.loop() + }) + alertController.addAction(manualLoopAction) + present(alertController, animated: true) } } diff --git a/Loop/Views/PotentialCarbEntryTableViewCell.swift b/Loop/Views/PotentialCarbEntryTableViewCell.swift new file mode 100644 index 0000000000..fac1d8060b --- /dev/null +++ b/Loop/Views/PotentialCarbEntryTableViewCell.swift @@ -0,0 +1,39 @@ +// +// PotentialCarbEntryTableViewCell.swift +// Loop +// +// Created by Michael Pangburn on 12/27/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +class PotentialCarbEntryTableViewCell: UITableViewCell { + @IBOutlet weak var valueLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.layoutMargins.left = separatorInset.left + contentView.layoutMargins.right = separatorInset.left + } + + override func awakeFromNib() { + super.awakeFromNib() + + resetViews() + } + + override func prepareForReuse() { + super.prepareForReuse() + + resetViews() + } + + private func resetViews() { + valueLabel.text = nil + dateLabel.text = nil + } +} diff --git a/Loop/en.lproj/InfoPlist.strings b/Loop/en.lproj/InfoPlist.strings index dd51a9cde8..3b377afb02 100644 --- a/Loop/en.lproj/InfoPlist.strings +++ b/Loop/en.lproj/InfoPlist.strings @@ -12,7 +12,7 @@ "NSFaceIDUsageDescription" = "Face ID is used to authenticate insulin bolus."; /* Privacy - Health Share Usage Description */ -"NSHealthShareUsageDescription" = "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation."; +"NSHealthShareUsageDescription" = "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake."; /* Privacy - Health Update Usage Description */ "NSHealthUpdateUsageDescription" = "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit."; diff --git a/Loop/fi.lproj/InfoPlist.strings b/Loop/fi.lproj/InfoPlist.strings index a46e1e3731..7bfd7cde86 100644 --- a/Loop/fi.lproj/InfoPlist.strings +++ b/Loop/fi.lproj/InfoPlist.strings @@ -8,11 +8,11 @@ "NSBluetoothPeripheralUsageDescription" = "Bluetoothin avulla kommunikoidaan insuliinipumpun ja glukoosinseurantalaitteen kanssa."; /* Privacy - Face ID Usage Description */ -"NSFaceIDUsageDescription" = "Face ID:tä käytetään vahvistamiseen annettaessa bolusta."; +"NSFaceIDUsageDescription" = "Face ID:tä käytetään annettavan boluksen vahvistamiseen."; /* Privacy - Health Share Usage Description */ -"NSHealthShareUsageDescription" = "Terveys-appin ateriatietoja käytetään glukoosivaikutusten määrittämiseen. Terveys-appin glukoositietoja käytetään graafeissa ja laskelmissa."; +"NSHealthShareUsageDescription" = "Terveys-sovelluksen ateriatietoja käytetään glukoosivaikutusten määrittämiseen. Terveys-sovelluksen glukoositietoja käytetään graafeissa ja laskelmissa. Unitiedot parantavat Apple Watch komplikaation käyttökokemusta."; /* Privacy - Health Update Usage Description */ -"NSHealthUpdateUsageDescription" = "Appin ja kellon kautta tallennetut hiilihydraattitiedot tallennetaan Terveys-appiin. Glukoosiseurannan kautta saadut glukoositiedot tallennetaan turvallisesti HealthKitiin."; +"NSHealthUpdateUsageDescription" = "Sovelluksen ja kellon kautta tallennetut hiilihydraattitiedot tallennetaan Terveys-sovellukseen. Glukoosiseurannan kautta saadut glukoositiedot tallennetaan turvallisesti HealthKitiin."; diff --git a/Loop/fi.lproj/Localizable.strings b/Loop/fi.lproj/Localizable.strings index 3c574dc828..57565860b6 100644 --- a/Loop/fi.lproj/Localizable.strings +++ b/Loop/fi.lproj/Localizable.strings @@ -1,5 +1,5 @@ /* The string format appended to active insulin that describes pending insulin. (1: pending insulin) */ -" (pending: %@)" = "(odottaa: %@)"; +" (pending: %@)" = "(odot.: %@)"; /* The format for an active override preset. (1: preset symbol)(2: preset name) */ "%@ %@" = "%1$@ %2$@"; @@ -38,16 +38,19 @@ "%1$@: %2$@" = "%1$@: %2$@"; /* Description of the prediction input effect for glucose momentum */ -"15 min glucose regression coefficient (b₁), continued with decay over 30 min" = "15 min glukoosin regressiokerroin (b₁), häipyen 30 min kuluessa."; +"15 min glucose regression coefficient (b₁), continued with decay over 30 min" = "15 min glukoosin regressiokerroin (b₁), hiipuen 30 min kuluessa"; /* Description of the prediction input effect for retrospective correction */ -"30 min comparison of glucose prediction vs actual, continued with decay over 60 min" = "30 min vertailu ennustetun ja todellisen glukoosin välillä, häipyen 60 min kuluessa."; +"30 min comparison of glucose prediction vs actual, continued with decay over 60 min" = "30 min vertailu ennustetun ja todellisen glukoosin välillä, hiipuen 60 min kuluessa"; /* Subtitle of Fiasp preset */ -"A model based on the published absorption of Fiasp insulin." = "Malli perustuen julkaistuihin Fiasp-insuliinin imeytymisaikoihin."; +"A model based on the published absorption of Fiasp insulin." = "Perustuu Fiasp-insuliinin julkaistuun imeytymismalliin."; /* Subtitle of Rapid-Acting – Adult preset */ -"A model based on the published absorption of Humalog, Novolog, and Apidra insulin in adults." = "Malli perustuen julkaistuihin Humalog-, Novorapid- ja Apidra-insuliinien imeytymisaikoihin aikuisilla."; +"A model based on the published absorption of Humalog, Novolog, and Apidra insulin in adults." = "Perustuu Humalog-, Novorapid- ja Apidra-insuliinien julkaistuun imeytymismalliin aikuisilla."; + +/* Title of the carb entry absorption time cell */ +"Absorption Time" = "Imeytymisaika"; /* Action to copy the recommended Bolus value to the actual Bolus Field */ "AcceptRecommendedBolus" = "HyväksySuositeltuBolus"; @@ -107,9 +110,13 @@ "Basal Rates" = "Basaalitasot"; /* The label of the bolus entry button - The notification title for a bolus failure */ + The notification title for a bolus failure + Title text for bolus screen (manual correction) */ "Bolus" = "Bolus"; +/* Alert title for an updated bolus recommendation */ +"Bolus Recommendation Updated" = "Bolussuositus päivitetty"; + /* The format string for bolus progress. (1: delivered volume)(2: total volume) */ "Bolused %1$@ of %2$@" = "Bolus %1$@ / %2$@"; @@ -125,10 +132,19 @@ /* Details for missing data error when carb effects are missing */ "Carb effects" = "Hiilihydraattivaikutus"; +/* Back button text for bolus screen to return to carb entry screen */ +"Carb Entry" = "Hiilihydraatit"; + /* The title of the carb ratios schedule screen The title text for the carb ratio schedule */ "Carb Ratios" = "Hiilihydraattisuhteet"; +/* The title of the view controller to create a new carb entry */ +"carb-entry-title-add" = "Lisää hiilihydraatteja"; + +/* The title of the view controller to edit an existing carb entry */ +"carb-entry-title-edit" = "Muokkaa hiilihydraatteja"; + /* Title of the prediction input effect for carbohydrates */ "Carbohydrates" = "Hiilihydraatit"; @@ -148,19 +164,22 @@ "Check that your pump is in range" = "Tarkista, että pumppu on riittävän lähellä"; /* Recovery suggestion when glucose data is missing */ -"Check your CGM data source" = "Tarkista CGM-datalähde"; +"Check your CGM data source" = "Tarkista CGM-tietolähde"; + +/* Carb entry section footer text explaining absorption time */ +"Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact." = "Valitse pidempi imeytymisaika isoille tai paljon rasvaa ja proteiineja sisältäville aterioille. Tämä on suuntaa antava tieto, eikä sen tarvitse olla tarkka."; /* The title text for the looping enabled switch cell */ -"Closed Loop" = "Closed Loop"; +"Closed Loop" = "Suljettu säätö"; /* The title of the action used to dismiss an error alert */ "com.loudnate.LoopKit.errorAlertActionTitle" = "OK"; /* The title of the configuration section in settings */ -"Configuration" = "Kokoonpano"; +"Configuration" = "Määritykset"; /* The error message displayed for configuration errors. (1: configuration error details) */ -"Configuration Error: %1$@" = "Kokoonpanovirhe: %1$@"; +"Configuration Error: %1$@" = "Määritysvirhe: %1$@"; /* The title of the continuous glucose monitor section in settings */ "Continuous Glucose Monitor" = "Glukoosinseuranta (CGM)"; @@ -172,15 +191,22 @@ /* Message when offering bolus recommendation even though bg is below range. (1: glucose value) */ "Current glucose of %1$@ is below correction range." = "Nykyinen glukoosi %1$@ on korjausalueen alapuolella."; -/* The title of the cell indicating a generic temporary override is enabled */ -"Custom Override" = "Tilapäisas"; +/* Name of custom override + The title of the cell indicating a generic temporary override is enabled */ +"Custom Override" = "Mukautettu tilapäisasetus"; /* The title of the Loggly customer token credential */ "Customer Token" = "Asiakastunniste"; +/* Title of the carb entry date picker cell */ +"Date" = "Aika"; + /* Button title to delete CGM */ "Delete CGM" = "Poista CGM"; +/* The button text to initiate a bolus */ +"Deliver" = "Annostele"; + /* Title text for delivery limits */ "Delivery Limits" = "Annostelurajat"; @@ -256,7 +282,7 @@ "Issue Report" = "Ongelmaraportti"; /* Glucose HUD accessibility hint */ -"Launches CGM app" = "Avaa CGM-appin"; +"Launches CGM app" = "Avaa CGM-sovelluksen"; /* The loading message for the diagnostic report screen */ "Loading..." = "Ladataan..."; @@ -270,6 +296,9 @@ /* The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop */ "Loop has not completed successfully in %@" = "Häiriö Loopin toiminnassa %@"; +/* Title text for bolus screen following a carb entry */ +"Meal Bolus" = "Ateriabolus"; + /* The error message for missing data. (1: missing data details) */ "Missing data: %1$@" = "Tiedot puuttuvat: %1$@"; @@ -282,8 +311,14 @@ /* The error message displayed for device connection errors. */ "No connected devices, or failure during device connection" = "Ei yhdistettyjä laitteita tai häiriö laiteyhteydessä"; +/* Button text to acknowledge an updated bolus recommendation alert */ +"OK" = "OK"; + /* The title text for the override presets */ -"Override Presets" = "Override Presets"; +"Override Presets" = "Tilapäisasetukset"; + +/* Name of pre-meal workout override */ +"Pre-Meal" = "Ennen ateriaa"; /* The label of the pre-meal mode toggle button */ "Pre-Meal Targets" = "Ennen ateriaa -tavoite"; @@ -339,9 +374,13 @@ /* Title of the prediction input effect for retrospective correction */ "Retrospective Correction" = "Retrospektiivinen korjaus"; -/* The title of the notification action to retry a bolus command */ +/* The button text for attempting a manual loop + The title of the notification action to retry a bolus command */ "Retry" = "Uudelleen"; +/* The button text to save a carb entry without bolusing */ +"Save without Bolusing" = "Tallenna ilman bolusta"; + /* The title of the services section in settings */ "Services" = "Palvelut"; @@ -355,7 +394,7 @@ "since %@" = "%@ jälkeen"; /* The title of the nightscout site URL credential */ -"Site URL" = "Sivuston URL"; +"Site URL" = "URL"; /* The format for the description of a temporary override start date */ "starting at %@" = "alkaa %@"; @@ -369,8 +408,11 @@ /* The subtitle of the cell displaying an action to resume insulin delivery */ "Tap to Resume" = "Jatka annostelua"; +/* Alert message for an updated bolus recommendation */ +"The bolus recommendation has updated. Please reconfirm the bolus amount." = "Bolussuositus on päivittynyt. Vahvista bolus uudelleen."; + /* Subtitle description of Walsh insulin model setting */ -"The legacy model used by Loop, allowing customization of action duration." = "Loopin aikaisemmin käyttämä insuliinimalli, joka mahdollistaa insuliinin vaikutusajan muokkauksen."; +"The legacy model used by Loop, allowing customization of action duration." = "Loopin vanha insuliinimalli, jossa voi muokata insuliinin vaikutusaikaa."; /* Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value) */ "The maximum bolus amount is %@ Units" = "Suurin sallittu bolus on %@ yksikköä"; @@ -390,6 +432,9 @@ /* Explanation of suspend threshold */ "When current or forecasted glucose is below the suspend threshold, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." = "Kun nykyinen tai ennustettu glukoosi on pysäytysrajan alapuolella, Loop ei suosittele bolusta ja suosittelee aina tilapäiseksi basaaliksi 0 yksikköä tunnissa."; +/* Name of legacy workout override */ +"Workout" = "Liikunta"; + /* The label of the workout mode toggle button */ "Workout Targets" = "Liikuntatavoitteet"; diff --git a/Loop/fi.lproj/Main.strings b/Loop/fi.lproj/Main.strings index 59ba2fb749..69debdf7b9 100644 --- a/Loop/fi.lproj/Main.strings +++ b/Loop/fi.lproj/Main.strings @@ -2,7 +2,7 @@ "3kU-n2-fha.title" = "Tila"; /* Class = "UILabel"; text = "3.5 U/hour @ 12:12 PM"; ObjectID = "5gz-kZ-iF1"; */ -"5gz-kZ-iF1.text" = "3.5 U/h @ 12:12 PM"; +"5gz-kZ-iF1.text" = "3.5 U/h @ 12:12"; /* Class = "UILabel"; text = "Bolus"; ObjectID = "5oA-6d-ZTL"; */ "5oA-6d-ZTL.text" = "Bolus"; @@ -25,6 +25,9 @@ /* Class = "UINavigationItem"; title = "Bolus"; ObjectID = "aiu-ZA-zVa"; */ "aiu-ZA-zVa.title" = "Bolus"; +/* Class = "UILabel"; text = "Food Type"; ObjectID = "ap1-M6-naG"; */ +"ap1-M6-naG.text" = "Ruokatyyppi"; + /* Class = "UILabel"; text = "Label"; ObjectID = "bIL-Ub-qYp"; */ "bIL-Ub-qYp.text" = "Nimiö"; @@ -44,7 +47,7 @@ "d3X-AN-tA5.text" = "g yhteensä"; /* Class = "UILabel"; text = "Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction."; ObjectID = "D4C-I2-dhA"; */ -"D4C-I2-dhA.text" = "Tulevia glukoosiarvoja ennustetaan yhdistämällä useita eri tekijöiden vaikutuksia. Tämän työkalun avulla voit havainnoida, kuinka ne vaikuttavat lopulliseen ennusteeseen laittamalla eri tekijöitä päälle/pois."; +"D4C-I2-dhA.text" = "Tulevia glukoosiarvoja ennustetaan yhdistämällä useiden eri tekijöiden vaikutuksia. Tämän työkalun avulla voit havainnoida, kuinka ne vaikuttavat lopulliseen ennusteeseen laittamalla niitä päälle/pois."; /* Class = "UILabel"; text = "Label"; ObjectID = "d6m-qV-wWi"; */ "d6m-qV-wWi.text" = "Nimiö"; @@ -61,18 +64,18 @@ /* Class = "UILabel"; text = "Observed"; ObjectID = "EAn-Ja-S1d"; */ "EAn-Ja-S1d.text" = "Havaittu"; -/* Class = "UILabel"; text = "Active Carbohydrates: 40g"; ObjectID = "hHZ-uY-aKw"; */ -"hHZ-uY-aKw.text" = "Aktiivinen hiilihydraatti: 40g"; - /* Class = "UITableViewController"; title = "Carbohydrate Effects"; ObjectID = "hZZ-2S-lrd"; */ "hZZ-2S-lrd.title" = "Hiilihydraattivaikutus"; /* Class = "UILabel"; text = "Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption."; ObjectID = "IxU-As-glo"; */ -"IxU-As-glo.text" = "Havaittuja glukoosin muutoksia, vähennettynä insuliinin annostelusta mallinnetuilla muutoksilla, voidaan käyttää hiilihydraattien imeytymisen arvioinnissa."; +"IxU-As-glo.text" = "Havaittua glukoosin muutosta, josta on vähennetty insuliinin mallinnettu vaikutus, voidaan käyttää hiilihydraattien imeytymisen arviointiin."; /* Class = "UILabel"; text = "Detail"; ObjectID = "J7x-W5-gwo"; */ "J7x-W5-gwo.text" = "Yksityiskohta"; +/* Class = "UILabel"; text = "Detail"; ObjectID = "jQv-xb-gwu"; */ +"jQv-xb-gwu.text" = "Yksityiskohta"; + /* Class = "UILabel"; text = "⚠ Glucose Predicted Below Range"; ObjectID = "k0g-P7-OVN"; */ "k0g-P7-OVN.text" = "⚠ Ennustettu glukoosi alueen alapuolella"; @@ -106,6 +109,9 @@ /* Class = "UILabel"; text = "An insulin activity model is used to estimate effects of insulin on glucose levels. An accurate model can help prevent insulin stacking and safely recommend corrective treatments."; ObjectID = "PJv-p9-cFe"; */ "PJv-p9-cFe.text" = "Insuliiniaktiivisuusmallia käytetään arvioimaan insuliinin vaikutuksia glukoositasoon. Tarkka malli voi auttaa estämään liian suuren insuliinimäärän kertymistä kehoon ja suosittelemaan turvallisia glukoosia korjaavia hoitotoimenpiteitä."; +/* Class = "UILabel"; text = "Food Type"; ObjectID = "qPH-vU-xlu"; */ +"qPH-vU-xlu.text" = "Ruokatyyppi"; + /* Class = "UILabel"; text = "eventually 92 mg/dL"; ObjectID = "Rse-x8-amW"; */ "Rse-x8-amW.text" = "ennuste 92 mg/dL"; @@ -115,20 +121,20 @@ /* Class = "UILabel"; text = "Glucose"; ObjectID = "tuw-av-A3x"; */ "tuw-av-A3x.text" = "Glukoosi"; +/* Class = "UINavigationItem"; title = "Add/Edit Carb Entry"; ObjectID = "Tz7-80-bJ7"; */ +"Tz7-80-bJ7.title" = "Lisää/muokkaa hiilihydraatteja"; + /* Class = "UILabel"; text = "Label"; ObjectID = "ufi-Kj-33k"; */ "ufi-Kj-33k.text" = "Nimiö"; -/* Class = "UILabel"; text = "Active Insulin: 1.5U"; ObjectID = "viU-7a-kbI"; */ -"viU-7a-kbI.text" = "Aktiivinen insuliini: 1.5U"; - /* Class = "UINavigationItem"; title = "Carbohydrates"; ObjectID = "Vpi-5b-bY5"; */ "Vpi-5b-bY5.title" = "Hiilihydraatit"; /* Class = "UITextField"; text = "4 hour"; ObjectID = "Wk3-xv-IM5"; */ "Wk3-xv-IM5.text" = "4 tuntia"; -/* Class = "UIButton"; normalTitle = "Deliver"; ObjectID = "Ya0-9b-ZAS"; */ -"Ya0-9b-ZAS.normalTitle" = "Annostele"; +/* Class = "UILabel"; text = "Amount Consumed"; ObjectID = "Wx8-Tf-FnG"; */ +"Wx8-Tf-FnG.text" = "Määrä"; /* Class = "UILabel"; text = "0"; ObjectID = "yn7-2M-jZz"; */ "yn7-2M-jZz.text" = "0"; diff --git a/Loop/sv.lproj/InfoPlist.strings b/Loop/sv.lproj/InfoPlist.strings index 6098bba476..8c66331009 100644 --- a/Loop/sv.lproj/InfoPlist.strings +++ b/Loop/sv.lproj/InfoPlist.strings @@ -2,17 +2,17 @@ "CFBundleName" = "$(PRODUCT_NAME)"; /* Privacy - Bluetooth Always Usage Description */ -"NSBluetoothAlwaysUsageDescription" = "Bluetooth används för att kommunicera med insulinpumpen och kontinuerlig glukosmätare."; +"NSBluetoothAlwaysUsageDescription" = "Bluetooth används för att kommunicera med insulinpump och CGM."; /* Privacy - Bluetooth Peripheral Usage Description */ -"NSBluetoothPeripheralUsageDescription" = "Bluetooth används för att kommunicera med insulinpumpen och kontinuerlig glukosmätare."; +"NSBluetoothPeripheralUsageDescription" = "Bluetooth används för att kommunicera med insulinpump och CGM."; /* Privacy - Face ID Usage Description */ -"NSFaceIDUsageDescription" = "Face ID avänds för att godkänna bolus."; +"NSFaceIDUsageDescription" = "Kräv Face ID för att kunna godkänna bolus."; /* Privacy - Health Share Usage Description */ "NSHealthShareUsageDescription" = "Kolhydratdata från Apple Health-databasen används för att bestämma effekten på glukosvärde. Glukosvärden från Apple Health-databasen används i diagram och för beräkning av förändring."; /* Privacy - Health Update Usage Description */ -"NSHealthUpdateUsageDescription" = "Kolhydratvärden inmatade i appen i klockan lagras i Apple Health-databasen. Glukosvärden mottagna från CGM lagras krypterat i HealthKit."; +"NSHealthUpdateUsageDescription" = "Kolhydratvärden inmatade i appen i klockan lagras i Apple Health-databasen. Glukosvärden mottagna från CGM lagras krypterat i HealthKit."; diff --git a/Loop/sv.lproj/Localizable.strings b/Loop/sv.lproj/Localizable.strings index f338a3503e..112c3e510a 100644 --- a/Loop/sv.lproj/Localizable.strings +++ b/Loop/sv.lproj/Localizable.strings @@ -29,7 +29,7 @@ "%1$@ U left: %2$@" = "%1$@ E kvar: %2$@"; /* The format for recommended temp basal rate and time. (1: localized rate number)(2: localized time) */ -"%1$@ U/hour @ %2$@" = "%1$@ E/timme @ %2$@"; +"%1$@ U/hour @ %2$@" = "%1$@ E/h @ %2$@"; /* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ "%1$@ v%2$@" = "%1$@ v%2$@"; @@ -38,16 +38,16 @@ "%1$@: %2$@" = "%1$@: %2$@"; /* Description of the prediction input effect for glucose momentum */ -"15 min glucose regression coefficient (b₁), continued with decay over 30 min" = "15 minuters glukosregressionskoeficient (b₁), fortsatt med 30 minuters avklingande"; +"15 min glucose regression coefficient (b₁), continued with decay over 30 min" = "15 minuters glukosregressionskoefficient (b₁), fortsatt med 30 minuters avklingande"; /* Description of the prediction input effect for retrospective correction */ "30 min comparison of glucose prediction vs actual, continued with decay over 60 min" = "30 min jämförelse av glukosprediktion och faktiskt värde, fortsatt med 60 miuters avklingande."; /* Subtitle of Fiasp preset */ -"A model based on the published absorption of Fiasp insulin." = "Insulinmodell baserad på publicerade studier av absorption av Fiasp insulin."; +"A model based on the published absorption of Fiasp insulin." = "Insulinmodell baserad på publicerade studier av absorption av Fiasp-insulin."; /* Subtitle of Rapid-Acting – Adult preset */ -"A model based on the published absorption of Humalog, Novolog, and Apidra insulin in adults." = "Insulinmodell baserad på publicerade studier av absorption av Humalog, Novolog, and Apidra-insulin hos vuxna."; +"A model based on the published absorption of Humalog, Novolog, and Apidra insulin in adults." = "Insulinmodell baserad på publicerade studier av absorption av Humalog-, Novolog- samt Apidra-insulin hos vuxna."; /* Action to copy the recommended Bolus value to the actual Bolus Field */ "AcceptRecommendedBolus" = "AcceptRecommendedBolus"; @@ -91,13 +91,13 @@ "API Secret" = "API Secret"; /* Confirmation message for deleting a CGM */ -"Are you sure you want to delete this CGM?" = "Säkert att du vill radera denna CGM?"; +"Are you sure you want to delete this CGM?" = "Är du säker på att du vill radera denna CGM?"; /* Format fragment for a specific time */ -"at %@" = "kl %@"; +"at %@" = "kl. %@"; /* The message displayed during a device authentication prompt for bolus specification */ -"Authenticate to Bolus %@ Units" = "Godkänn bolus på %@ Enheter"; +"Authenticate to Bolus %@ Units" = "Godkänn bolus på %@ enheter"; /* Details for configuration error when basal rate schedule is missing */ "Basal Rate Schedule" = "Basaldosschema"; @@ -111,7 +111,7 @@ "Bolus" = "Bolus"; /* The format string for bolus progress. (1: delivered volume)(2: total volume) */ -"Bolused %1$@ of %2$@" = "Gett bolus %1$@ av %2$@"; +"Bolused %1$@ of %2$@" = "Givit bolus %1$@ av %2$@"; /* The format string for bolus in progress showing total volume. (1: total volume) */ "Bolusing %1$@" = "Ger bolus %1$@ "; @@ -127,13 +127,13 @@ /* The title of the carb ratios schedule screen The title text for the carb ratio schedule */ -"Carb Ratios" = "Insulinkvoter"; +"Carb Ratios" = "Kolhydratskvoter"; /* Title of the prediction input effect for carbohydrates */ "Carbohydrates" = "Kolhydrater"; /* Description of the prediction input effect for carbohydrates. (1: The glucose unit string) */ -"Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)" = "Kolhydrater abbsorberade (g) ÷ Insulinkvot (g/E) × Insulinkänslighet (%1$@/E)"; +"Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)" = "Absorberade kolhydrater (g) ÷ Insulinkvot (g/E) × Insulinkänslighet (%1$@/E)"; /* The notification alert describing a low pump battery */ "Change the pump battery immediately" = "Byt pumpbatteri nu"; @@ -142,13 +142,13 @@ "Change the pump reservoir now" = "Byt pumpreservoar nu"; /* Details for configuration error when one or more loop settings are missing */ -"Check settings" = "Kontrollera inställningar"; +"Check settings" = "Kontrollera inställningarna"; /* Recovery suggestion when reservoir data is missing */ -"Check that your pump is in range" = "Kontrollera att pump är inom räckhåll"; +"Check that your pump is in range" = "Kontrollera att pumpen är inom räckhåll"; /* Recovery suggestion when glucose data is missing */ -"Check your CGM data source" = "Kontrollera din CGMs datakälla"; +"Check your CGM data source" = "Kontrollera din CGM:s datakälla"; /* The title text for the looping enabled switch cell */ "Closed Loop" = "Sluten loop"; @@ -188,7 +188,7 @@ "Disables" = "Stänger av"; /* The action hint of the workout mode toggle button when disabled */ -"Enables" = "Sätter på"; +"Enables" = "Slår på"; /* The placeholder text instructing users to enter a suspend treshold */ "Enter suspend threshold" = "Ange tröskelvärde"; @@ -200,10 +200,10 @@ "Error Resuming" = "Fel vid försök att återgå"; /* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %@" = "Eventuellt %@"; +"Eventually %@" = "Förväntat %@"; /* The title of the alert describing a maximum bolus validation error */ -"Exceeds Maximum Bolus" = "Överstiger maxdos"; +"Exceeds Maximum Bolus" = "Överstiger maxbolusdos"; /* Title of insulin model preset */ "Fiasp" = "Fiasp"; @@ -253,7 +253,7 @@ "Invalid data: %1$@" = "Ogiltigt värde: %1$@"; /* The title text for the issue report cell */ -"Issue Report" = "Skapa rapport"; +"Issue Report" = "Skapa felrapport"; /* Glucose HUD accessibility hint */ "Launches CGM app" = "Startar CGM-app"; @@ -265,10 +265,10 @@ "Loggly" = "Loggly"; /* The notification title for a loop failure */ -"Loop Failure" = "Loopfel"; +"Loop Failure" = "Loop-fel"; /* The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop */ -"Loop has not completed successfully in %@" = "Loop har inte körts på %@"; +"Loop has not completed successfully in %@" = "Loop har inte lyckats köra på %@"; /* The error message for missing data. (1: missing data details) */ "Missing data: %1$@" = "Saknar data: %1$@"; @@ -280,7 +280,7 @@ "Nightscout" = "Nightscout"; /* The error message displayed for device connection errors. */ -"No connected devices, or failure during device connection" = "Ingen ansluten enhet, eller fel vid anslutning"; +"No connected devices, or failure during device connection" = "Ingen ansluten enhet, eller fel vid anslutning till enhet"; /* The title text for the override presets */ "Override Presets" = "Override förinställningar"; @@ -289,55 +289,55 @@ "Pre-Meal Targets" = "Målvärden före måltid"; /* Message when offering bolus recommendation even though bg is below range and minBG is in future. (1: glucose time)(2: glucose number) */ -"Predicted glucose at %1$@ is %2$@." = "Predikterat glukos at %1$@ är %2$@."; +"Predicted glucose at %1$@ is %2$@." = "Förväntat glukosvärde vid %1$@ är %2$@."; /* Notice message when recommending bolus when BG is below the suspend threshold. (1: glucose value) */ -"Predicted glucose of %1$@ is below your suspend threshold setting." = "Predikterat glukos på %1$@ är under ditt tröskelvärde."; +"Predicted glucose of %1$@ is below your suspend threshold setting." = "Förväntat glukosvärde %1$@ är under ditt tröskelvärde."; /* Format string describing retrospective glucose prediction comparison. (1: Predicted glucose)(2: Actual glucose)(3: difference) */ -"Predicted: %1$@\nActual: %2$@ (%3$@)" = "Predikterat: %1$@\nFaktiskt: %2$@ (%3$@)"; +"Predicted: %1$@\nActual: %2$@ (%3$@)" = "Förväntat: %1$@\nFaktiskt: %2$@ (%3$@)"; /* The title of the pump section in settings */ "Pump" = "Pump"; /* The notification title for a low pump battery */ -"Pump Battery Low" = "Lågt batteri i pump"; +"Pump Battery Low" = "Låg batterinivå i pump"; /* The error message when pump data is too old to be used. (1: pump data age in minutes) */ -"Pump data is %1$@ old" = "Pumpvärden är %1$@ gamla"; +"Pump data is %1$@ old" = "Pumpvärdena är %1$@ gamla"; /* Details for configuration error when pump manager is missing */ -"Pump Manager" = "Pump Manager"; +"Pump Manager" = "Pumphantering"; /* The notification title for an empty pump reservoir */ -"Pump Reservoir Empty" = "Pumpreservoar är tom"; +"Pump Reservoir Empty" = "Pumpreservoaren är tom"; /* The notification title for a low pump reservoir */ -"Pump Reservoir Low" = "Pumpreservoar är låg"; +"Pump Reservoir Low" = "Pumpreservoaren har låg nivå"; /* The title of the cell indicating the pump is suspended */ -"Pump Suspended" = "Pump pausad"; +"Pump Suspended" = "Pumpen är pausad"; /* Title of insulin model preset */ -"Rapid-Acting – Adults" = "Snabbverkande – Vuxna"; +"Rapid-Acting – Adults" = "Snabbverkande – vuxna"; /* Title of insulin model preset */ -"Rapid-Acting – Children" = "Snabbverkande – Barn"; +"Rapid-Acting – Children" = "Snabbverkande – barn"; /* The error message when a recommendation has expired. (1: age of recommendation in minutes) */ -"Recommendation expired: %1$@ old" = "Rekommendation utgått %1$@ gammal"; +"Recommendation expired: %1$@ old" = "Rekommendationen gick ut för %1$@ sedan"; /* The title of the cell displaying a recommended temp basal value */ -"Recommended Basal" = "Rekommenderad basal"; +"Recommended Basal" = "Rekommenderat basalvärde"; /* Accessibility hint describing recommended bolus units */ -"Recommended Bolus: %@ Units" = "Rekommenderad bolus: %@ Enheter"; +"Recommended Bolus: %@ Units" = "Rekommenderad bolus: %@ enheter"; /* Details for missing data error when reservoir data is missing */ "Reservoir" = "Reservoar"; /* Title of the prediction input effect for retrospective correction */ -"Retrospective Correction" = "Retrospektiv korrektion"; +"Retrospective Correction" = "Retrospektiv korrigering"; /* The title of the notification action to retry a bolus command */ "Retry" = "Försök igen"; @@ -349,16 +349,16 @@ "Settings" = "Inställningar"; /* Loop Completion HUD accessibility hint */ -"Shows last loop error" = "Visar senaste loopfel"; +"Shows last loop error" = "Visar senaste loopfelet"; /* Format fragment for a start time */ "since %@" = "sedan %@"; /* The title of the nightscout site URL credential */ -"Site URL" = "Sida URL"; +"Site URL" = "Adress (URL)"; /* The format for the description of a temporary override start date */ -"starting at %@" = "Börjar kl %@"; +"starting at %@" = "Börjar kl. %@"; /* The title of the cell indicating a bolus is being sent */ "Starting Bolus" = "Påbörjar bolus"; @@ -370,25 +370,25 @@ "Tap to Resume" = "Tryck för att återuppta"; /* Subtitle description of Walsh insulin model setting */ -"The legacy model used by Loop, allowing customization of action duration." = "Äldre modell använd av Loop, som tillåter anpassing av effektduration."; +"The legacy model used by Loop, allowing customization of action duration." = "Äldre modell använd av Loop, vilken tillåter anpassing av effektvaraktighet."; /* Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value) */ -"The maximum bolus amount is %@ Units" = "Maximala bolusdos är %@ Eheter"; +"The maximum bolus amount is %@ Units" = "Den maximala bolusdosen är %@ enheter"; /* The short unit display string for international units of insulin */ "U" = "E"; /* The format for the description of a temporary override end date */ -"until %@" = "till %@"; +"until %@" = "fram till %@"; /* The title of the alert controller used to select a duration for workout targets */ -"Use Workout Glucose Targets" = "Avänd målvärden för träning"; +"Use Workout Glucose Targets" = "Använd målvärden för träning"; /* Title of insulin model setting */ "Walsh" = "Walsh"; /* Explanation of suspend threshold */ -"When current or forecasted glucose is below the suspend threshold, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." = "När nuvarande, eller predikterat, glukosvärde är under tröskelvärde, kommer Loop inte att rekommendera en bolus och kommer dessutom alltid att föreslå en temporär basal på 0 eheter per timme."; +"When current or forecasted glucose is below the suspend threshold, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." = "När nuvarande eller förväntat slutglukosvärde är under tröskelvärdet kommer Loop inte att rekommendera en bolus, utan kommer alltid att föreslå en temporär basal på 0 enheter per timme."; /* The label of the workout mode toggle button */ "Workout Targets" = "Målvärden för träning"; diff --git a/Loop/sv.lproj/Main.strings b/Loop/sv.lproj/Main.strings index b81d79ebdf..542a7859fd 100644 --- a/Loop/sv.lproj/Main.strings +++ b/Loop/sv.lproj/Main.strings @@ -2,7 +2,7 @@ "3kU-n2-fha.title" = "Status"; /* Class = "UILabel"; text = "3.5 U/hour @ 12:12 PM"; ObjectID = "5gz-kZ-iF1"; */ -"5gz-kZ-iF1.text" = "3.5 E/timme kl 12:12 PM"; +"5gz-kZ-iF1.text" = "3.5 E/h kl. 12:12"; /* Class = "UILabel"; text = "Bolus"; ObjectID = "5oA-6d-ZTL"; */ "5oA-6d-ZTL.text" = "Bolus"; @@ -11,22 +11,22 @@ "5TX-kX-nBo.text" = "Pump-ID"; /* Class = "UITextField"; accessibilityLabel = "Bolus Amount"; ObjectID = "7LT-50-ZzK"; */ -"7LT-50-ZzK.accessibilityLabel" = "Bolusvärde"; +"7LT-50-ZzK.accessibilityLabel" = "Bolusmängd"; /* Class = "UITextField"; placeholder = "0.0"; ObjectID = "7LT-50-ZzK"; */ -"7LT-50-ZzK.placeholder" = "0.0"; +"7LT-50-ZzK.placeholder" = "0,0"; /* Class = "UILabel"; text = "Predicted"; ObjectID = "87H-N1-0vJ"; */ -"87H-N1-0vJ.text" = "Predikterat"; +"87H-N1-0vJ.text" = "Förväntat"; /* Class = "UILabel"; text = "Detail"; ObjectID = "aCb-Qs-bpu"; */ -"aCb-Qs-bpu.text" = "Detail"; +"aCb-Qs-bpu.text" = "Detalj"; /* Class = "UINavigationItem"; title = "Bolus"; ObjectID = "aiu-ZA-zVa"; */ "aiu-ZA-zVa.title" = "Bolus"; /* Class = "UILabel"; text = "Label"; ObjectID = "bIL-Ub-qYp"; */ -"bIL-Ub-qYp.text" = "Label"; +"bIL-Ub-qYp.text" = "Etikett"; /* Class = "UILabel"; text = "Glucose Change"; ObjectID = "bq4-98-cQU"; */ "bq4-98-cQU.text" = "Glukosförändring"; @@ -44,7 +44,7 @@ "d3X-AN-tA5.text" = "g totalt"; /* Class = "UILabel"; text = "Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction."; ObjectID = "D4C-I2-dhA"; */ -"D4C-I2-dhA.text" = "Framtida glukosvärde uppskattas genom kombination av flera inmatningar. Använd det här verktyget för att jämföra deras påverkan på utfall "; +"D4C-I2-dhA.text" = "Förväntat glukosvärde beräknas genom kombination av flera inmatningar. Använd det här verktyget för att jämföra hur de påverkar det förväntade resultatet."; /* Class = "UILabel"; text = "Label"; ObjectID = "d6m-qV-wWi"; */ "d6m-qV-wWi.text" = "Titel"; @@ -56,28 +56,28 @@ "DyC-Sv-qP8.text" = "ENHETER"; /* Class = "UILabel"; text = "eventually 92 mg/dL"; ObjectID = "E41-FN-nkk"; */ -"E41-FN-nkk.text" = "eventuellt 92 mg/dL"; +"E41-FN-nkk.text" = "förväntat 5,1 mmol/l"; /* Class = "UILabel"; text = "Observed"; ObjectID = "EAn-Ja-S1d"; */ "EAn-Ja-S1d.text" = "Observerad"; /* Class = "UILabel"; text = "Active Carbohydrates: 40g"; ObjectID = "hHZ-uY-aKw"; */ -"hHZ-uY-aKw.text" = "Aktiva kolhydrater: 40g"; +"hHZ-uY-aKw.text" = "Aktiva kolhydrater: 40 g"; /* Class = "UITableViewController"; title = "Carbohydrate Effects"; ObjectID = "hZZ-2S-lrd"; */ "hZZ-2S-lrd.title" = "Kolhydrateffekter"; /* Class = "UILabel"; text = "Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption."; ObjectID = "IxU-As-glo"; */ -"IxU-As-glo.text" = "Observerad glukosförändring, med substraherad förändring modellerad från isulindoser, kan användas för att uppskatta kolhydratabsorption."; +"IxU-As-glo.text" = "Observerad glukosförändring, med borttagen förändring modellerad från insulindoser, kan användas för att uppskatta kolhydratabsorptionen."; /* Class = "UILabel"; text = "Detail"; ObjectID = "J7x-W5-gwo"; */ "J7x-W5-gwo.text" = "Detalj"; /* Class = "UILabel"; text = "⚠ Glucose Predicted Below Range"; ObjectID = "k0g-P7-OVN"; */ -"k0g-P7-OVN.text" = "⚠ Predikterat glukosvärde under målvärde"; +"k0g-P7-OVN.text" = "⚠ Förväntat glukosvärde hamnar under målvärdet"; /* Class = "UILabel"; text = "Recommended Basal"; ObjectID = "k3F-Na-7mn"; */ -"k3F-Na-7mn.text" = "Rekommenderad basal"; +"k3F-Na-7mn.text" = "Rekommenderad basaldos"; /* Class = "UILabel"; text = "Label"; ObjectID = "Krd-Aa-ret"; */ "Krd-Aa-ret.text" = "Label"; @@ -98,16 +98,16 @@ "OFA-qT-ZAg.text" = "Label"; /* Class = "UITableViewController"; title = "Predicted Glucose"; ObjectID = "PA3-sP-cWY"; */ -"PA3-sP-cWY.title" = "Uppskattat glukosvärde"; +"PA3-sP-cWY.title" = "Förväntat glukosvärde"; /* Class = "UITableViewController"; title = "Insulin Model"; ObjectID = "pi6-Dh-72V"; */ "pi6-Dh-72V.title" = "Insulinmodell"; /* Class = "UILabel"; text = "An insulin activity model is used to estimate effects of insulin on glucose levels. An accurate model can help prevent insulin stacking and safely recommend corrective treatments."; ObjectID = "PJv-p9-cFe"; */ -"PJv-p9-cFe.text" = "En insulinaktivitetsmodell används för att uppskatta effekterna på blodglukosnivåer. En korrekt modell kan hjälpa till att förhindra att insulindoser lagras på varandra ge en mer säker korrigerande rekommenderad behandling."; +"PJv-p9-cFe.text" = "En insulinaktivitetsmodell används för att beräkna effekterna på blodglukosnivåerna. En rättvisande modell kan hjälpa till att förhindra att flera insulindoser läggs på varandra, och istället rekommendera en säkrare korrigering."; /* Class = "UILabel"; text = "eventually 92 mg/dL"; ObjectID = "Rse-x8-amW"; */ -"Rse-x8-amW.text" = "eventuellt 92 mg/dL"; +"Rse-x8-amW.text" = "Förväntat 5,1 mmol/l"; /* Class = "UILabel"; text = "g COB"; ObjectID = "SQx-au-ZcM"; */ "SQx-au-ZcM.text" = "g COB"; @@ -119,7 +119,7 @@ "ufi-Kj-33k.text" = "Titel"; /* Class = "UILabel"; text = "Active Insulin: 1.5U"; ObjectID = "viU-7a-kbI"; */ -"viU-7a-kbI.text" = "Aktivt insulin: 1.5E"; +"viU-7a-kbI.text" = "Aktivt insulin: 1.5 E"; /* Class = "UINavigationItem"; title = "Carbohydrates"; ObjectID = "Vpi-5b-bY5"; */ "Vpi-5b-bY5.title" = "Kolhydrater"; @@ -128,7 +128,7 @@ "Wk3-xv-IM5.text" = "4 timmar"; /* Class = "UIButton"; normalTitle = "Deliver"; ObjectID = "Ya0-9b-ZAS"; */ -"Ya0-9b-ZAS.normalTitle" = "Ge bolus"; +"Ya0-9b-ZAS.normalTitle" = "Ge bolusdos"; /* Class = "UILabel"; text = "0"; ObjectID = "yn7-2M-jZz"; */ "yn7-2M-jZz.text" = "0"; diff --git a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift index e2f0877ed8..e65cdd1fb7 100644 --- a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift +++ b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift @@ -38,9 +38,20 @@ extension ExponentialInsulinModelPreset { return .minutes(55) } } + + var effectDelay: TimeInterval { + switch self { + case .humalogNovologAdult: + return .minutes(10) + case .humalogNovologChild: + return .minutes(10) + case .fiasp: + return .minutes(10) + } + } var model: InsulinModel { - return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity) + return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity, delay: effectDelay) } } @@ -49,6 +60,10 @@ extension ExponentialInsulinModelPreset: InsulinModel { public var effectDuration: TimeInterval { return model.effectDuration } + + public var delay: TimeInterval { + return model.delay + } public func percentEffectRemaining(at time: TimeInterval) -> Double { return model.percentEffectRemaining(at: time) diff --git a/LoopCore/LoopCompletionFreshness.swift b/LoopCore/LoopCompletionFreshness.swift new file mode 100644 index 0000000000..8da8d0e2b6 --- /dev/null +++ b/LoopCore/LoopCompletionFreshness.swift @@ -0,0 +1,57 @@ +// +// LoopCompletionFreshness.swift +// Loop +// +// Created by Pete Schwamb on 1/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum LoopCompletionFreshness { + case fresh + case aging + case stale + case unknown + + public var maxAge: TimeInterval? { + switch self { + case .fresh: + return TimeInterval(minutes: 6) + case .aging: + return TimeInterval(minutes: 16) + case .stale: + return TimeInterval(hours: 12) + case .unknown: + return nil + } + } + + public init(age: TimeInterval?) { + guard let age = age else { + self = .unknown + return + } + + switch age { + case let t where t <= LoopCompletionFreshness.fresh.maxAge!: + self = .fresh + case let t where t <= LoopCompletionFreshness.aging.maxAge!: + self = .aging + case let t where t <= LoopCompletionFreshness.stale.maxAge!: + self = .stale + default: + self = .unknown + } + } + + public init(lastCompletion: Date?, at date: Date = Date()) { + guard let lastCompletion = lastCompletion else { + self = .unknown + return + } + + self = LoopCompletionFreshness(age: date.timeIntervalSince(lastCompletion)) + } + +} diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index b524e3495c..2b23925b33 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -36,8 +36,13 @@ public struct LoopSettings: Equatable { /// The interval over which to aggregate changes in glucose for retrospective correction public let retrospectiveCorrectionGroupingInterval = TimeInterval(minutes: 30) - /// The amount of time since a given date that data should be considered valid - public let recencyInterval = TimeInterval(minutes: 15) + /// The amount of time since a given date that input data should be considered valid + public let inputDataRecencyInterval = TimeInterval(minutes: 15) + + /// Loop completion aging category limits + public let completionFreshLimit = TimeInterval(minutes: 6) + public let completionAgingLimit = TimeInterval(minutes: 16) + public let completionStaleLimit = TimeInterval(hours: 12) public let batteryReplacementDetectionThreshold = 0.5 diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 54db2d46c5..38f6158874 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -20,6 +20,8 @@ extension UserDefaults { case loopSettings = "com.loopkit.Loop.loopSettings" case insulinSensitivitySchedule = "com.loudnate.Naterade.InsulinSensitivitySchedule" case overrideHistory = "com.tidepool.loopkit.overrideHistory" + case lastBedtimeQuery = "com.loopkit.Loop.lastBedtimeQuery" + case bedtime = "com.loopkit.Loop.bedtime" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -151,4 +153,30 @@ extension UserDefaults { set(newValue?.rawValue, forKey: Key.overrideHistory.rawValue) } } + + public var lastBedtimeQuery: Date? { + get { + if let rawValue = object(forKey: Key.lastBedtimeQuery.rawValue) as? Date { + return rawValue + } else { + return nil + } + } + set { + set(newValue, forKey: Key.lastBedtimeQuery.rawValue) + } + } + + public var bedtime: Date? { + get { + if let rawValue = object(forKey: Key.bedtime.rawValue) as? Date { + return rawValue + } else { + return nil + } + } + set { + set(newValue, forKey: Key.bedtime.rawValue) + } + } } diff --git a/LoopUI/Charts/DoseChart.swift b/LoopUI/Charts/DoseChart.swift index c8854e4446..3c85992ffb 100644 --- a/LoopUI/Charts/DoseChart.swift +++ b/LoopUI/Charts/DoseChart.swift @@ -9,19 +9,30 @@ import Foundation import LoopKit import SwiftCharts +fileprivate struct DosePointsCache { + let basal: [ChartPoint] + let basalFill: [ChartPoint] + let bolus: [ChartPoint] + let highlight: [ChartPoint] +} public class DoseChart: ChartProviding { public init() { + doseEntries = [] + } + + public var doseEntries: [DoseEntry] { + didSet { + pointsCache = nil + } } - public private(set) var basalDosePoints: [ChartPoint] = [] - public private(set) var bolusDosePoints: [ChartPoint] = [] - - /// Dose points selectable when highlighting - public private(set) var allDosePoints: [ChartPoint] = [] { + private var pointsCache: DosePointsCache? { didSet { - if let lastDate = allDosePoints.last?.x as? ChartAxisValueDate { - endDate = lastDate.date + if let pointsCache = pointsCache { + if let lastDate = pointsCache.highlight.last?.x as? ChartAxisValueDate { + endDate = lastDate.date + } } } } @@ -41,17 +52,19 @@ public class DoseChart: ChartProviding { public extension DoseChart { func didReceiveMemoryWarning() { - basalDosePoints = [] - bolusDosePoints = [] - allDosePoints = [] + pointsCache = nil doseChartCache = nil } func generate(withFrame frame: CGRect, xAxisModel: ChartAxisModel, xAxisValues: [ChartAxisValue], axisLabelSettings: ChartLabelSettings, guideLinesLayerSettings: ChartGuideLinesLayerSettings, colors: ChartColorPalette, chartSettings: ChartSettings, labelsWidthY: CGFloat, gestureRecognizer: UIGestureRecognizer?, traitCollection: UITraitCollection) -> Chart { let integerFormatter = NumberFormatter.integer + + let startDate = ChartAxisValueDate.dateFromScalar(xAxisValues.first!.scalar) + + let points = generateDosePoints(startDate: startDate) - let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(basalDosePoints + bolusDosePoints + doseDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: log10(2) / 2, axisValueGenerator: { ChartAxisValueDoubleLog(screenLocDouble: $0, formatter: integerFormatter, labelSettings: axisLabelSettings) }, addPaddingSegmentIfEdge: true) + let yAxisValues = ChartAxisValuesStaticGenerator.generateYAxisValuesWithChartPoints(points.basal + points.bolus + doseDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: log(2) / 2, axisValueGenerator: { ChartAxisValueDoubleLog(screenLocDouble: $0, formatter: integerFormatter, labelSettings: axisLabelSettings) }, addPaddingSegmentIfEdge: true) let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: colors.axisLine, labelSpaceReservationMode: .fixed(labelsWidthY)) @@ -60,23 +73,23 @@ public extension DoseChart { let (xAxisLayer, yAxisLayer, innerFrame) = (coordsSpace.xAxisLayer, coordsSpace.yAxisLayer, coordsSpace.chartInnerFrame) // The dose area - let lineModel = ChartLineModel(chartPoints: basalDosePoints, lineColor: colors.doseTint, lineWidth: 2, animDuration: 0, animDelay: 0) + let lineModel = ChartLineModel(chartPoints: points.basal, lineColor: colors.doseTint, lineWidth: 2, animDuration: 0, animDelay: 0) let doseLine = ChartPointsLineLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, lineModels: [lineModel]) let doseArea = ChartPointsFillsLayer( xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, fills: [ChartPointsFill( - chartPoints: basalDosePoints, + chartPoints: points.basalFill, fillColor: colors.doseTint.withAlphaComponent(0.5), createContainerPoints: false )] ) let bolusLayer: ChartPointsScatterDownTrianglesLayer? - - if bolusDosePoints.count > 0 { - bolusLayer = ChartPointsScatterDownTrianglesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: bolusDosePoints, displayDelay: 0, itemSize: CGSize(width: 12, height: 12), itemFillColor: colors.doseTint) + + if points.bolus.count > 0 { + bolusLayer = ChartPointsScatterDownTrianglesLayer(xAxis: xAxisLayer.axis, yAxis: yAxisLayer.axis, chartPoints: points.bolus, displayDelay: 0, itemSize: CGSize(width: 12, height: 12), itemFillColor: colors.doseTint) } else { bolusLayer = nil } @@ -100,7 +113,7 @@ public extension DoseChart { xAxisLayer: xAxisLayer, yAxisLayer: yAxisLayer, axisLabelSettings: axisLabelSettings, - chartPoints: allDosePoints, + chartPoints: points.highlight, tintColor: colors.doseTint, gestureRecognizer: gestureRecognizer ) @@ -119,17 +132,21 @@ public extension DoseChart { return Chart(frame: frame, innerFrame: innerFrame, settings: chartSettings, layers: layers.compactMap { $0 }) } -} - -public extension DoseChart { - func setDoseEntries(_ doseEntries: [DoseEntry]) { + + private func generateDosePoints(startDate: Date) -> DosePointsCache { + + guard pointsCache == nil else { + return pointsCache! + } + let dateFormatter = DateFormatter(timeStyle: .short) let doseFormatter = NumberFormatter.dose - var basalDosePoints = [ChartPoint]() - var bolusDosePoints = [ChartPoint]() - var allDosePoints = [ChartPoint]() - + var basalPoints = [ChartPoint]() + var basalFillPoints = [ChartPoint]() + var bolusPoints = [ChartPoint]() + var highlightPoints = [ChartPoint]() + for entry in doseEntries { let time = entry.endDate.timeIntervalSince(entry.startDate) @@ -138,11 +155,11 @@ public extension DoseChart { let y = ChartAxisValueDoubleLog(actualDouble: entry.unitsInDeliverableIncrements, unitString: "U", formatter: doseFormatter) let point = ChartPoint(x: x, y: y) - bolusDosePoints.append(point) - allDosePoints.append(point) + bolusPoints.append(point) + highlightPoints.append(point) } else if time > 0 { // TODO: Display the DateInterval - let startX = ChartAxisValueDate(date: entry.startDate, formatter: dateFormatter) + let startX = ChartAxisValueDate(date: max(startDate, entry.startDate), formatter: dateFormatter) let endX = ChartAxisValueDate(date: entry.endDate, formatter: dateFormatter) let zero = ChartAxisValueInt(0) let rate = entry.netBasalUnitsPerHour @@ -158,19 +175,20 @@ public extension DoseChart { } else { valuePoints = [] } + + basalFillPoints += [ChartPoint(x: startX, y: zero)] + valuePoints + [ChartPoint(x: endX, y: zero)] + + if entry.startDate > startDate { + basalPoints += [ChartPoint(x: startX, y: zero)] + } + basalPoints += valuePoints + [ChartPoint(x: endX, y: zero)] - basalDosePoints += [ - ChartPoint(x: startX, y: zero) - ] + valuePoints + [ - ChartPoint(x: endX, y: zero) - ] - - allDosePoints += valuePoints + highlightPoints += valuePoints } } - - self.basalDosePoints = basalDosePoints - self.bolusDosePoints = bolusDosePoints - self.allDosePoints = allDosePoints + + let pointsCache = DosePointsCache(basal: basalPoints, basalFill: basalFillPoints, bolus: bolusPoints, highlight: highlightPoints) + self.pointsCache = pointsCache + return pointsCache } } diff --git a/LoopUI/Views/GlucoseHUDView.swift b/LoopUI/Views/GlucoseHUDView.swift index c5380db1c9..fae00b8bb8 100644 --- a/LoopUI/Views/GlucoseHUDView.swift +++ b/LoopUI/Views/GlucoseHUDView.swift @@ -91,27 +91,27 @@ public final class GlucoseHUDView: BaseHUDView { } } - public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, sensor: SensorDisplayable?) { + public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, staleGlucoseAge: TimeInterval, sensor: SensorDisplayable?) { var accessibilityStrings = [String]() let time = timeFormatter.string(from: glucoseStartDate) caption?.text = time - let sensorDataCurrent = glucoseStartDate.timeIntervalSinceNow > TimeInterval(minutes: -15) + let glucoseValueCurrent = glucoseStartDate.timeIntervalSinceNow > -staleGlucoseAge let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) if let valueString = numberFormatter.string(from: glucoseQuantity) { - if sensorDataCurrent { + if glucoseValueCurrent { glucoseLabel.text = valueString } else { - glucoseLabel.text = "-" + glucoseLabel.text = "---" } accessibilityStrings.append(String(format: LocalizedString("%1$@ at %2$@", comment: "Accessbility format value describing glucose: (1: glucose number)(2: glucose time)"), valueString, time)) } var unitStrings = [unit.localizedShortUnitString] - if let trend = sensor?.trendType, sensorDataCurrent { + if let trend = sensor?.trendType, glucoseValueCurrent { unitStrings.append(trend.symbol) accessibilityStrings.append(trend.localizedDescription) } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 1f62d14dde..705daec9d7 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -8,6 +8,7 @@ import UIKit import LoopKitUI +import LoopCore public final class LoopCompletionHUDView: BaseHUDView { @@ -17,14 +18,7 @@ public final class LoopCompletionHUDView: BaseHUDView { return 1 } - enum Freshness { - case fresh - case aging - case stale - case unknown - } - - private(set) var freshness = Freshness.unknown { + private(set) var freshness = LoopCompletionFreshness.unknown { didSet { updateTintColor() } @@ -128,17 +122,8 @@ public final class LoopCompletionHUDView: BaseHUDView { @objc private func updateDisplay(_: Timer?) { if let date = lastLoopCompleted { let ago = abs(min(0, date.timeIntervalSinceNow)) - - switch ago { - case let t where t <= .minutes(6): - freshness = .fresh - case let t where t <= .minutes(16): - freshness = .aging - case let t where t <= .hours(12): - freshness = .stale - default: - freshness = .unknown - } + + freshness = LoopCompletionFreshness(age: ago) if let timeString = formatter.string(from: ago) { switch traitCollection.preferredContentSizeCategory { diff --git a/LoopUI/fi.lproj/Localizable.strings b/LoopUI/fi.lproj/Localizable.strings index a2c595ee7b..b2b65c2bf0 100644 --- a/LoopUI/fi.lproj/Localizable.strings +++ b/LoopUI/fi.lproj/Localizable.strings @@ -1,5 +1,5 @@ /* Format string describing the time interval since the last completion date. (1: The localized date components */ -"%@ ago" = "%@ sitten"; +"%@ ago" = "%@"; /* The format string describing the basal rate. */ "%@ U" = "%@ U"; @@ -17,10 +17,10 @@ "Closed loop" = "Suljettu säätö"; /* Accessbility format label describing the time interval since the last completion date. (1: The localized date components) */ -"Loop ran %@ ago" = "Viimeisin säätökierros %@ sitten"; +"Loop ran %@ ago" = "Viimeisin säätö %@ sitten"; /* Accessibility label component for glucose HUD describing an invalid state */ -"Needs attention" = "Tarvitsee huomion"; +"Needs attention" = "Tarvitsee huomiota"; /* Accessbility hint describing completion HUD for an open loop */ "Open loop" = "Avoin säätö"; @@ -29,5 +29,5 @@ "Unknown" = "Tuntematon"; /* Acessibility label describing completion HUD waiting for first run */ -"Waiting for first run" = "Odotetaan ensimmäistä ..."; +"Waiting for first run" = "Odotetaan ensimmäistä säätöä"; diff --git a/LoopUI/sv.lproj/Localizable.strings b/LoopUI/sv.lproj/Localizable.strings index 01f6ebf04e..2acafe0cbf 100644 --- a/LoopUI/sv.lproj/Localizable.strings +++ b/LoopUI/sv.lproj/Localizable.strings @@ -8,7 +8,7 @@ "%1$@ at %2$@" = "%1$@ at %2$@"; /* Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time) */ -"%1$@ units per hour at %2$@" = "%1$@ eheter per timme kl %2$@"; +"%1$@ units per hour at %2$@" = "%1$@ enheter/h kl. %2$@"; /* Format string describing glucose units per minute (1: glucose unit string) */ "%1$@/min" = "%1$@/min"; @@ -17,10 +17,10 @@ "Closed loop" = "Sluten loop"; /* Accessbility format label describing the time interval since the last completion date. (1: The localized date components) */ -"Loop ran %@ ago" = "Loop kördes %@ sedan"; +"Loop ran %@ ago" = "Loop kördes för %@ sedan"; /* Accessibility label component for glucose HUD describing an invalid state */ -"Needs attention" = "Kräver uppmärksamhet"; +"Needs attention" = "Kräver åtgärd"; /* Accessbility hint describing completion HUD for an open loop */ "Open loop" = "Öppen loop"; diff --git a/Scripts/make_scenario.py b/Scripts/make_scenario.py index 103fd33cf2..0d7949b78f 100644 --- a/Scripts/make_scenario.py +++ b/Scripts/make_scenario.py @@ -110,7 +110,9 @@ def make_glucose_values(): def make_basal_doses(): return [ - BasalDose(1.0, hours(-0.5), hours(0.5)), + BasalDose(1.2, hours(-1.5), hours(0.5)), + BasalDose(0.9, hours(-1.0), hours(0.5)), + BasalDose(0.8, hours(-0.5), hours(0.5)) ] diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index cab92ddae8..7e1086cab3 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -8,10 +8,13 @@ import ClockKit import WatchKit - +import LoopCore +import os.log final class ComplicationController: NSObject, CLKComplicationDataSource { + private let log = OSLog(category: "ComplicationController") + // MARK: - Timeline Configuration func getSupportedTimeTravelDirections(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimeTravelDirections) -> Void) { @@ -76,11 +79,18 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { updateChartManagerIfNeeded(completion: { let entry: CLKComplicationTimelineEntry? - - if let context = ExtensionDelegate.shared().loopManager.activeContext, - let glucoseDate = context.glucoseDate, - glucoseDate.timeIntervalSinceNow.minutes >= -15, - let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, chartGenerator: self.makeChart) + + let settings = ExtensionDelegate.shared().loopManager.settings + let timelineDate = Date() + + self.log.default("Updating current complication timeline entry") + + if let context = ExtensionDelegate.shared().loopManager.activeContext, + let template = CLKComplicationTemplate.templateForFamily(complication.family, + from: context, + at: timelineDate, + recencyInterval: settings.inputDataRecencyInterval, + chartGenerator: self.makeChart) { switch complication.family { case .graphicRectangular: @@ -88,7 +98,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { default: template.tintColor = .tintColor } - entry = CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template) + entry = CLKComplicationTimelineEntry(date: timelineDate, complicationTemplate: template) } else { entry = nil } @@ -97,26 +107,48 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { }) } - func getTimelineEntries(for complication: CLKComplication, before date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { - // Call the handler with the timeline entries prior to the given date - handler(nil) - } - func getTimelineEntries(for complication: CLKComplication, after date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { updateChartManagerIfNeeded { let entries: [CLKComplicationTimelineEntry]? - - if let context = ExtensionDelegate.shared().loopManager.activeContext, - let glucoseDate = context.glucoseDate, - glucoseDate.timeIntervalSince(date) > 0, - let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, chartGenerator: self.makeChart) + + let settings = ExtensionDelegate.shared().loopManager.settings + + guard let context = ExtensionDelegate.shared().loopManager.activeContext, + let glucoseDate = context.glucoseDate else { - template.tintColor = UIColor.tintColor - entries = [CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template)] - } else { - entries = nil + handler(nil) + return } - + + var futureChangeDates: [Date] = [ + // Stale glucose date: just a second after glucose expires + glucoseDate + settings.inputDataRecencyInterval + 1, + ] + + if let loopLastRunDate = context.loopLastRunDate { + let freshnessCategories = [ + LoopCompletionFreshness.fresh, + LoopCompletionFreshness.aging, + LoopCompletionFreshness.stale + ].compactMap( { $0.maxAge }) + futureChangeDates.append(contentsOf: freshnessCategories.map { loopLastRunDate + $0 + 1}) + } + + entries = futureChangeDates.filter { $0 > date }.compactMap({ (futureChangeDate) -> CLKComplicationTimelineEntry? in + if let template = CLKComplicationTemplate.templateForFamily(complication.family, + from: context, + at: futureChangeDate, + recencyInterval: settings.inputDataRecencyInterval, + chartGenerator: self.makeChart) + { + template.tintColor = UIColor.tintColor + self.log.default("Adding complication timeline entry for date %{public}@", String(describing: futureChangeDate)) + return CLKComplicationTimelineEntry(date: futureChangeDate, complicationTemplate: template) + } else { + return nil + } + }) + handler(entries) } } diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index 3b3eddcf63..07ace72b3b 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -29,6 +29,8 @@ class HUDInterfaceController: WKInterfaceController { } } } + + loopManager.requestGlucoseBackfillIfNecessary() } override func didDeactivate() { @@ -48,15 +50,15 @@ class HUDInterfaceController: WKInterfaceController { return } - glucoseLabel.setHidden(true) + glucoseLabel.setText("---") + glucoseLabel.setHidden(false) eventualGlucoseLabel.setHidden(true) - if let glucose = activeContext.glucose, let unit = activeContext.preferredGlucoseUnit { + if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.preferredGlucoseUnit, glucoseDate.timeIntervalSinceNow > -loopManager.settings.inputDataRecencyInterval { let formatter = NumberFormatter.glucoseFormatter(for: unit) if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { let trend = activeContext.glucoseTrend?.symbol ?? "" glucoseLabel.setText(glucoseValue + trend) - glucoseLabel.setHidden(false) } if let eventualGlucose = activeContext.eventualGlucose { diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index 57c293029a..547f8f2a5e 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -10,37 +10,83 @@ import ClockKit import HealthKit import LoopKit import Foundation - +import LoopCore extension CLKComplicationTemplate { - static func templateForFamily(_ family: CLKComplicationFamily, from context: WatchContext, chartGenerator makeChart: () -> UIImage?) -> CLKComplicationTemplate? { + static func templateForFamily( + _ family: CLKComplicationFamily, + from context: WatchContext, + at date: Date, + recencyInterval: TimeInterval, + chartGenerator makeChart: () -> UIImage? + ) -> CLKComplicationTemplate? { guard let glucose = context.glucose, let unit = context.preferredGlucoseUnit else { return nil } - - return templateForFamily(family, glucose: glucose, unit: unit, date: context.glucoseDate, trend: context.glucoseTrend, eventualGlucose: context.eventualGlucose, chartGenerator: makeChart) + + return templateForFamily(family, + glucose: glucose, + unit: unit, + glucoseDate: context.glucoseDate, + trend: context.glucoseTrend, + eventualGlucose: context.eventualGlucose, + at: date, + loopLastRunDate: context.loopLastRunDate, + recencyInterval: recencyInterval, + chartGenerator: makeChart) } static func templateForFamily( _ family: CLKComplicationFamily, glucose: HKQuantity, unit: HKUnit, - date: Date?, + glucoseDate: Date?, trend: GlucoseTrend?, eventualGlucose: HKQuantity?, + at date: Date, + loopLastRunDate: Date?, + recencyInterval: TimeInterval, chartGenerator makeChart: () -> UIImage? ) -> CLKComplicationTemplate? { let formatter = NumberFormatter.glucoseFormatter(for: unit) - - guard let glucoseString = formatter.string(from: glucose.doubleValue(for: unit)), - let date = date else - { + + guard let glucoseDate = glucoseDate else { return nil } + + let glucoseString: String + let trendString: String + + let isGlucoseStale = date.timeIntervalSince(glucoseDate) > recencyInterval + + if isGlucoseStale { + glucoseString = "---" + trendString = "" + } else { + guard let formattedGlucose = formatter.string(from: glucose.doubleValue(for: unit)) else { + return nil + } + glucoseString = formattedGlucose + trendString = trend?.symbol ?? " " + } + + let loopCompletionFreshness = LoopCompletionFreshness(lastCompletion: loopLastRunDate, at: date) + + let tintColor: UIColor + + switch loopCompletionFreshness { + case .fresh: + tintColor = .tintColor + case .aging: + tintColor = .agingColor + case .stale: + tintColor = .staleColor + case .unknown: + tintColor = .disabledButtonColor + } - let trendString = trend?.symbol ?? " " let glucoseAndTrend = "\(glucoseString)\(trendString)" var accessibilityStrings = [glucoseString] @@ -49,7 +95,15 @@ extension CLKComplicationTemplate { } let glucoseAndTrendText = CLKSimpleTextProvider(text: glucoseAndTrend, shortText: glucoseString, accessibilityLabel: accessibilityStrings.joined(separator: ", ")) - let timeText = CLKRelativeDateTextProvider(date: date, style: .natural, units: .minute) + + let timeText: CLKTextProvider + + if let loopLastRunDate = loopLastRunDate { + timeText = CLKRelativeDateTextProvider(date: loopLastRunDate, style: .natural, units: [.minute, .hour, .day]) + } else { + timeText = CLKTextProvider() + } + timeText.tintColor = tintColor let timeFormatter = DateFormatter() timeFormatter.dateStyle = .none @@ -93,14 +147,13 @@ extension CLKComplicationTemplate { template.textProvider = CLKSimpleTextProvider(text: String(format: format, arguments: [ glucoseAndTrend, eventualGlucoseText, - timeFormatter.string(from: date) + timeFormatter.string(from: glucoseDate) ] )) return template case .graphicCorner: if #available(watchOSApplicationExtension 5.0, *) { let template = CLKComplicationTemplateGraphicCornerStackText() - timeText.tintColor = .tintColor template.innerTextProvider = timeText template.outerTextProvider = glucoseAndTrendText return template @@ -112,7 +165,7 @@ extension CLKComplicationTemplate { let template = CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText() template.centerTextProvider = CLKSimpleTextProvider(text: glucoseString) template.bottomTextProvider = CLKSimpleTextProvider(text: trendString) - template.gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .tintColor, fillFraction: 1) + template.gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: tintColor, fillFraction: 1) return template } else { return nil @@ -121,7 +174,17 @@ extension CLKComplicationTemplate { if #available(watchOSApplicationExtension 5.0, *) { let template = CLKComplicationTemplateGraphicBezelCircularText() guard - let circularTemplate = templateForFamily(.graphicCircular, glucose: glucose, unit: unit, date: date, trend: trend, eventualGlucose: eventualGlucose, chartGenerator: makeChart) as? CLKComplicationTemplateGraphicCircular + let circularTemplate = templateForFamily(.graphicCircular, + glucose: glucose, + unit: unit, + glucoseDate: glucoseDate, + trend: trend, + eventualGlucose: eventualGlucose, + at: date, + loopLastRunDate: loopLastRunDate, + recencyInterval: recencyInterval, + chartGenerator: makeChart + ) as? CLKComplicationTemplateGraphicCircular else { fatalError("\(#function) invoked with .graphicCircular must return a subclass of CLKComplicationTemplateGraphicCircular") } @@ -135,7 +198,6 @@ extension CLKComplicationTemplate { if #available(watchOSApplicationExtension 5.0, *) { let template = CLKComplicationTemplateGraphicRectangularLargeImage() template.imageProvider = CLKFullColorImageProvider(fullColorImage: makeChart() ?? UIImage()) - timeText.tintColor = .tintColor template.textProvider = CLKTextProvider(byJoining: [glucoseAndTrendText, timeText], separator: " ") return template } else { diff --git a/WatchApp Extension/Extensions/UIColor.swift b/WatchApp Extension/Extensions/UIColor.swift index b7088fe575..bac52cb432 100644 --- a/WatchApp Extension/Extensions/UIColor.swift +++ b/WatchApp Extension/Extensions/UIColor.swift @@ -40,7 +40,11 @@ extension UIColor { static let chartNowLine = HIGWhiteColor().withAlphaComponent(0.2) static let chartPlatter = HIGWhiteColorDark() - + + static let agingColor = HIGYellowColor() + + static let staleColor = HIGRedColor() + // MARK: - HIG colors // See: https://developer.apple.com/watch/human-interface-guidelines/visual-design/#color diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 7985b76db6..dc97fd8e24 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -77,6 +77,9 @@ extension LoopDataManager { dispatchPrecondition(condition: .onQueue(.main)) if activeContext == nil || context.shouldReplace(activeContext!) { + if let newGlucoseSample = context.newGlucoseSample { + self.glucoseStore.addGlucose(newGlucoseSample) { (_) in } + } activeContext = context } } @@ -108,7 +111,7 @@ extension LoopDataManager { NotificationCenter.default.post(name: LoopDataManager.didUpdateContextNotification, object: self) } } - + @discardableResult func requestGlucoseBackfillIfNecessary() -> Bool { dispatchPrecondition(condition: .onQueue(.main)) diff --git a/WatchApp Extension/fi.lproj/Localizable.strings b/WatchApp Extension/fi.lproj/Localizable.strings index 2429e4ab1e..42effaed9a 100644 --- a/WatchApp Extension/fi.lproj/Localizable.strings +++ b/WatchApp Extension/fi.lproj/Localizable.strings @@ -1,11 +1,11 @@ /* HUD row title for COB */ -"Active Carbs" = "Aktiivinen hiilihydr."; +"Active Carbs" = "Akt. hiilari"; /* HUD row title for IOB */ -"Active Insulin" = "Aktiivinen insuliini"; +"Active Insulin" = "Akt. insuliini"; /* Title of the user activity for adding carbs */ -"Add Carb Entry" = "Lisää hiilihydraatteja"; +"Add Carb Entry" = "Lisää hiilari"; /* The title of the alert controller displayed after a bolus attempt fails */ "Bolus Failed" = "Bolus epäonnistui"; @@ -16,19 +16,19 @@ /* The recovery message displayed after a bolus attempt fails The recovery message displayed after a carb entry send attempt fails The recovery message displayed after a glucose range override send attempt fails */ -"Make sure your iPhone is nearby and try again" = "Varmista, että iPhone on riittävän lähellä ja yritä uudeleen"; +"Make sure your iPhone is nearby and try again" = "Varmista, että iPhone on riittävän lähellä ja yritä uudelleen"; /* HUD row title for Net Basal Rate */ -"Net Basal Rate" = "Nettobasaali"; +"Net Basal Rate" = "Basaali netto"; /* The text for the Watch button for enabling a temporary override */ -"Override" = "Tilapäisas"; +"Override" = "Tilapäisas."; /* The label and value showing the recommended bolus */ "Rec: %@ U" = "Suosit: %@ U"; /* HUD row title for remaining reservoir volume */ -"Reservoir Volume" = "Säiliön tila"; +"Reservoir Volume" = "Säiliön määrä"; /* The title of the alert controller displayed after a carb entry send attempt fails The title of the alert controller displayed after a glucose range override send attempt fails */ diff --git a/WatchApp Extension/sv.lproj/Localizable.strings b/WatchApp Extension/sv.lproj/Localizable.strings index 6b129d3f88..34a0cdd6fa 100644 --- a/WatchApp Extension/sv.lproj/Localizable.strings +++ b/WatchApp Extension/sv.lproj/Localizable.strings @@ -16,10 +16,10 @@ /* The recovery message displayed after a bolus attempt fails The recovery message displayed after a carb entry send attempt fails The recovery message displayed after a glucose range override send attempt fails */ -"Make sure your iPhone is nearby and try again" = "Säkerställ att telefon är inom räckhåll och försök igen"; +"Make sure your iPhone is nearby and try again" = "Säkerställ att telefonen är inom räckhåll och försök igen"; /* HUD row title for Net Basal Rate */ -"Net Basal Rate" = "Netto basaldos"; +"Net Basal Rate" = "Nettobasaldos"; /* The text for the Watch button for enabling a temporary override */ "Override" = "Override"; @@ -32,7 +32,7 @@ /* The title of the alert controller displayed after a carb entry send attempt fails The title of the alert controller displayed after a glucose range override send attempt fails */ -"Send Failed" = "Skicka misslyckades"; +"Send Failed" = "Sändning misslyckades"; /* The short unit display string for international units of insulin */ "U" = "E"; diff --git a/WatchApp/fi.lproj/Interface.strings b/WatchApp/fi.lproj/Interface.strings index 5a25daef1b..98ed9614cd 100644 --- a/WatchApp/fi.lproj/Interface.strings +++ b/WatchApp/fi.lproj/Interface.strings @@ -2,16 +2,16 @@ "0fo-Z3-hTi.title" = "🌮"; /* Class = "WKInterfaceButton"; title = "Add Carbs"; ObjectID = "b6f-3I-jki"; */ -"b6f-3I-jki.title" = "Lisää hiilihydraatteja"; +"b6f-3I-jki.title" = "Lisää hiilari"; /* Class = "WKInterfaceLabel"; text = "—"; ObjectID = "CsQ-fc-KLC"; */ "CsQ-fc-KLC.text" = "—"; -/* Class = "WKInterfaceLabel"; text = "15"; ObjectID = "CWt-7U-cnK"; */ +/* Class = "WKInterfaceLabel"; text = "--"; ObjectID = "CWt-7U-cnK"; */ "CWt-7U-cnK.text" = "15"; /* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "HIILIHYDRAATTEJA YHT."; +"dea-qG-va8.text" = "HIILARI YHT."; /* Class = "WKInterfaceButton"; accessibilityLabel = "Subtract"; ObjectID = "Dh9-HV-fXy"; */ "Dh9-HV-fXy.accessibilityLabel" = "Vähennä"; @@ -41,7 +41,7 @@ "eu3-pj-GH3.title" = "+"; /* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Ennen ateriaa"; +"f5G-bS-9pd.text" = "E. ateriaa"; /* Class = "WKInterfaceMenuItem"; title = "3 hours"; ObjectID = "fR1-7h-SNe"; */ "fR1-7h-SNe.title" = "3 tuntia"; @@ -56,7 +56,7 @@ "hjF-xr-cwO.title" = "−"; /* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Hiilihydraatit"; +"hln-CI-MRP.text" = "Hiilari"; /* Class = "WKInterfaceLabel"; text = "—"; ObjectID = "IRi-4t-ESO"; */ "IRi-4t-ESO.text" = "—"; @@ -77,7 +77,7 @@ "MZU-QV-PtZ.text" = "OTSIKKO"; /* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Override"; +"nC0-X3-oFJ.text" = "Tilapäisas."; /* Class = "WKInterfaceButton"; title = "Bolus"; ObjectID = "Qsq-p5-1J0"; */ "Qsq-p5-1J0.title" = "Bolus"; @@ -95,7 +95,7 @@ "T4U-wP-dSW.text" = "Nimiö"; /* Class = "WKInterfaceLabel"; text = "10:09 AM"; ObjectID = "Ury-of-vQg"; */ -"Ury-of-vQg.text" = "10:09 AM"; +"Ury-of-vQg.text" = "10:09"; /* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ "UVY-pa-SUL.text" = "🏃‍♀️"; @@ -106,11 +106,14 @@ /* Class = "WKInterfaceMenuItem"; title = "1 hour"; ObjectID = "vL1-NA-WZ1"; */ "vL1-NA-WZ1.title" = "1 tunti"; +/* Class = "WKInterfaceLabel"; text = "Turn Digital Crown to bolus"; ObjectID = "Xcr-jO-0IQ"; */ +"Xcr-jO-0IQ.text" = "Vahvista bolus pyörittämällä Digital Crownia"; + /* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ "XkS-y5-khE.text" = ""; /* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTIIVINEN HIILIHYDRAATTI"; +"ycL-5X-a05.text" = "AKT. HIILARI"; /* Class = "WKInterfaceLabel"; text = "---"; ObjectID = "yl8-ZP-c3l"; */ "yl8-ZP-c3l.text" = "---"; diff --git a/WatchApp/sv.lproj/Interface.strings b/WatchApp/sv.lproj/Interface.strings index 8e87c7a290..d43289bf96 100644 --- a/WatchApp/sv.lproj/Interface.strings +++ b/WatchApp/sv.lproj/Interface.strings @@ -11,7 +11,7 @@ "CWt-7U-cnK.text" = "15"; /* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "Kolh totalt"; +"dea-qG-va8.text" = "Kolh. totalt"; /* Class = "WKInterfaceButton"; accessibilityLabel = "Subtract"; ObjectID = "Dh9-HV-fXy"; */ "Dh9-HV-fXy.accessibilityLabel" = "Subtrahera"; @@ -56,7 +56,7 @@ "hjF-xr-cwO.title" = "−"; /* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Kolh"; +"hln-CI-MRP.text" = "Kolh."; /* Class = "WKInterfaceLabel"; text = "—"; ObjectID = "IRi-4t-ESO"; */ "IRi-4t-ESO.text" = "—"; @@ -71,7 +71,7 @@ "Mhe-aR-kQQ.text" = "—"; /* Class = "WKInterfaceLabel"; text = "0.000"; ObjectID = "mpK-zY-UvA"; */ -"mpK-zY-UvA.text" = "0.000"; +"mpK-zY-UvA.text" = "0,000"; /* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ "MZU-QV-PtZ.text" = "TITLE"; @@ -95,7 +95,7 @@ "T4U-wP-dSW.text" = "Label"; /* Class = "WKInterfaceLabel"; text = "10:09 AM"; ObjectID = "Ury-of-vQg"; */ -"Ury-of-vQg.text" = "10:09 AM"; +"Ury-of-vQg.text" = "10:09"; /* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ "UVY-pa-SUL.text" = "🏃‍♀️"; @@ -110,7 +110,7 @@ "XkS-y5-khE.text" = ""; /* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTIVA KOLH"; +"ycL-5X-a05.text" = "AKTIVA KOLH."; /* Class = "WKInterfaceLabel"; text = "---"; ObjectID = "yl8-ZP-c3l"; */ "yl8-ZP-c3l.text" = "---";