diff --git a/.gitignore b/.gitignore index f238f37c9f..2d772d5f67 100644 --- a/.gitignore +++ b/.gitignore @@ -77,9 +77,12 @@ VersionOverride.xcconfig Loop/DerivedAssets.xcassets/* WatchApp/DerivedAssets.xcassets/* +Loop\ Widget\ Extension/DerivedAssets.xcassets/* # ...except, keep Contents.json !Loop/DerivedAssets.xcassets/Contents.json !WatchApp/DerivedAssets.xcassets/Contents.json +!Loop\ Widget\ Extension/DerivedAssets.xcassets/Contents.json Loop/DerivedAssetsOverride.xcassets WatchApp/DerivedAssetsOverride.xcassets +Loop\ Widget\ Extension/DerivedAssetsOverride.xcassets diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index fff5f98f07..0c6440b564 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -266,7 +266,8 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* adultChildInsulinModelSelectionEnabled: \(adultChildInsulinModelSelectionEnabled)", "* profileExpirationSettingsViewEnabled: \(profileExpirationSettingsViewEnabled)", "* missedMealNotifications: \(missedMealNotifications)", - "* allowAlgorithmExperiments: \(allowAlgorithmExperiments)" + "* allowAlgorithmExperiments: \(allowAlgorithmExperiments)", + "* allowExperimentalFeatures: \(allowExperimentalFeatures)" ].joined(separator: "\n") } } @@ -290,6 +291,20 @@ extension FeatureFlagConfiguration { #endif } + var allowExperimentalFeatures: Bool { + #if EXPERIMENTAL_FEATURES_ENABLED + return true + #elseif EXPERIMENTAL_FEATURES_ENABLED_CONDITIONALLY + if debugEnabled { + return true + } else { + return UserDefaults.appGroup?.allowExperimentalFeatures ?? false + } + #else + return false + #endif + } + var allowSimulators: Bool { #if SIMULATORS_ENABLED return true diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index 79c771d8c6..bee1f32894 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -160,8 +160,8 @@ extension GlucoseDisplayableContext: RawRepresentable { trendType = nil } - if let trendRateUnit = rawValue["trendRateUnit"] as? String, let trendRateValue = rawValue["trendRateValue"] as? Double { - trendRate = HKQuantity(unit: HKUnit(from: trendRateUnit), doubleValue: trendRateValue) + if let trendRateValue = rawValue["trendRateValue"] as? Double { + trendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue) } else { trendRate = nil } @@ -181,7 +181,6 @@ extension GlucoseDisplayableContext: RawRepresentable { ] raw["trendType"] = trendType?.rawValue if let trendRate = trendRate { - raw["trendRateUnit"] = HKUnit.milligramsPerDeciliterPerMinute.unitString raw["trendRateValue"] = trendRate.doubleValue(for: HKUnit.milligramsPerDeciliterPerMinute) } raw["glucoseRangeCategory"] = glucoseRangeCategory?.rawValue diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index e032d0ec8c..3ce3adebf1 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -75,8 +75,8 @@ final class WatchContext: RawRepresentable { if let rawGlucoseTrend = rawValue["gt"] as? GlucoseTrend.RawValue { glucoseTrend = GlucoseTrend(rawValue: rawGlucoseTrend) } - if let glucoseTrendRateUnitString = rawValue["gtru"] as? String, let glucoseTrendRateValue = rawValue["gtrv"] as? Double { - glucoseTrendRate = HKQuantity(unit: HKUnit(from: glucoseTrendRateUnitString), doubleValue: glucoseTrendRateValue) + if let glucoseTrendRateValue = rawValue["gtrv"] as? Double { + glucoseTrendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: glucoseTrendRateValue) } glucoseDate = rawValue["gd"] as? Date glucoseIsDisplayOnly = rawValue["gdo"] as? Bool diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/AppIcon.appiconset/Contents.json b/Loop Widget Extension/Bootstrap/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 9221b9bb1a..0000000000 --- a/Loop Widget Extension/Bootstrap/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/Contents.json rename to Loop Widget Extension/DefaultAssets.xcassets/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/WidgetBackground.colorset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/WidgetBackground.colorset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/WidgetBackground.colorset/Contents.json rename to Loop Widget Extension/DefaultAssets.xcassets/WidgetBackground.colorset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/WidgetSecondaryBackground.colorset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/WidgetSecondaryBackground.colorset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/WidgetSecondaryBackground.colorset/Contents.json rename to Loop Widget Extension/DefaultAssets.xcassets/WidgetSecondaryBackground.colorset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/bolus.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/bolus.imageset/Contents.json rename to Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/bolus.imageset/bolus.pdf b/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/bolus.pdf similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/bolus.imageset/bolus.pdf rename to Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/bolus.pdf diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/carbs.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/carbs.imageset/Contents.json rename to Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/carbs.imageset/Meal.pdf b/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Meal.pdf similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/carbs.imageset/Meal.pdf rename to Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Meal.pdf diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/premeal.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/premeal.imageset/Contents.json rename to Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/premeal.imageset/Pre-Meal.pdf b/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Pre-Meal.pdf similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/premeal.imageset/Pre-Meal.pdf rename to Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Pre-Meal.pdf diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/workout.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/workout.imageset/Contents.json rename to Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/workout.imageset/workout.pdf b/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/workout.pdf similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/workout.imageset/workout.pdf rename to Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/workout.pdf diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/AccentColor.colorset/Contents.json b/Loop Widget Extension/DerivedAssets.xcassets/Contents.json similarity index 51% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/AccentColor.colorset/Contents.json rename to Loop Widget Extension/DerivedAssets.xcassets/Contents.json index eb87897008..73c00596a7 100644 --- a/Loop Widget Extension/Bootstrap/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Loop Widget Extension/DerivedAssets.xcassets/Contents.json @@ -1,9 +1,4 @@ { - "colors" : [ - { - "idiom" : "universal" - } - ], "info" : { "author" : "xcode", "version" : 1 diff --git a/Loop Widget Extension/DerivedAssetsBase.xcassets/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop Widget Extension/DerivedAssetsBase.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/fresh.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/fresh.colorset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/glucose.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/glucose.colorset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/insulin.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/insulin.colorset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json diff --git a/Loop Widget Extension/Bootstrap/Assets.xcassets/warning.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/warning.colorset/Contents.json similarity index 100% rename from Loop Widget Extension/Bootstrap/Assets.xcassets/warning.colorset/Contents.json rename to Loop Widget Extension/DerivedAssetsBase.xcassets/warning.colorset/Contents.json diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 51821673c7..a1a9ee7c75 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -10,10 +10,20 @@ 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; + 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB7582A60BF2E0075748A /* EditMode.swift */; }; + 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */; }; + 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */; }; + 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */; }; + 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */; }; + 147EFE8E2A8BCC5500272438 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */; }; + 147EFE902A8BCD8000272438 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */; }; + 147EFE922A8BCD8A00272438 /* DerivedAssetsBase.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */; }; 1481F9BB28DA26F4004C5AEB /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; + 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */; }; + 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */; }; + 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */; }; 14B1735E28AED9EC006CCD7C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */; }; 14B1736028AED9EC006CCD7C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */; }; - 14B1736528AED9EE006CCD7C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 14B1736428AED9EE006CCD7C /* Assets.xcassets */; }; 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736E28AEDBF6006CCD7C /* BasalView.swift */; }; 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */; }; @@ -31,6 +41,7 @@ 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 14B1738128AEDC70006CCD7C /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; + 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */; }; 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */; }; 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219C2469F1F5000EBBDE /* AlertStore.swift */; }; 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 1D080CBB2473214A00356610 /* AlertStore.xcdatamodeld */; }; @@ -245,7 +256,6 @@ 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 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 */; }; 894F6DD3243BCBDB00CCE676 /* Environment+SizeClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */; }; @@ -394,7 +404,6 @@ C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; C11613492983096D00777E7C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C11613472983096D00777E7C /* InfoPlist.strings */; }; C116134C2983096D00777E7C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C116134A2983096D00777E7C /* Localizable.strings */; }; - C117B7952A743C0E00FDD015 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; C11B9D5E286778D000500CF8 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D5D286778D000500CF8 /* LoopKitUI.framework */; }; C11B9D62286779C000500CF8 /* MockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D60286779C000500CF8 /* MockKit.framework */; }; @@ -416,6 +425,7 @@ C159C82A286785E300A86EC0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C8212867859800A86EC0 /* MockKitUI.framework */; }; C159C82D2867876500A86EC0 /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */; }; C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C82E286787EF00A86EC0 /* LoopKit.framework */; }; + C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */; }; C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */; }; @@ -732,15 +742,27 @@ /* Begin PBXFileReference section */ 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Widget Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = ""; }; + 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = ""; }; + 14B1735C28AED9EC006CCD7C /* SmallStatusWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SmallStatusWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; + 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodView.swift; sourceTree = ""; }; + 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowAbsorptionTimeWorksView.swift; sourceTree = ""; }; + 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryViewModel.swift; sourceTree = ""; }; + 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryView.swift; sourceTree = ""; }; + 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodDetailView.swift; sourceTree = ""; }; 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - 14B1736428AED9EE006CCD7C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; + 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; + 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; sourceTree = ""; }; 14B1736628AED9EE006CCD7C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopWidgetExtension.entitlements; sourceTree = ""; }; 14B1736E28AEDBF6006CCD7C /* BasalView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalView.swift; sourceTree = ""; }; 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemStatusWidget.swift; sourceTree = ""; }; 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseView.swift; sourceTree = ""; }; 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; + 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.swift; sourceTree = ""; }; 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredAlert.swift; sourceTree = ""; }; 1D05219C2469F1F5000EBBDE /* AlertStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStore.swift; sourceTree = ""; }; 1D080CBC2473214A00356610 /* AlertStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AlertStore.xcdatamodel; sourceTree = ""; }; @@ -1165,7 +1187,6 @@ 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 = ""; }; 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+SizeClass.swift"; sourceTree = ""; }; @@ -1764,7 +1785,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C117B7952A743C0E00FDD015 /* SwiftCharts in Frameworks */, + C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1803,6 +1824,9 @@ 14B1736128AED9EC006CCD7C /* Loop Widget Extension */ = { isa = PBXGroup; children = ( + 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */, + 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */, + 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */, 84AA81D42A4A2813000B658B /* Bootstrap */, 84AA81D12A4A2778000B658B /* Components */, 84AA81D92A4A2966000B658B /* Helpers */, @@ -2170,6 +2194,7 @@ A9CBE457248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift */, A9B996F127238705002DC09C /* DosingDecisionStore.swift */, A9CBE459248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift */, + 142CB7582A60BF2E0075748A /* EditMode.swift */, A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */, A9DCF2D525B0F3C500C89088 /* LoopUIColorPalette+Default.swift */, 89E267FE229267DF00A3F2AF /* Optional.swift */, @@ -2197,7 +2222,6 @@ isa = PBXGroup; children = ( 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */, - 892D7C5023B54A14008A9656 /* CarbEntryViewController.swift */, 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */, 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */, C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */, @@ -2214,15 +2238,20 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( + 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */, C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */, 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */, + 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */, 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, + 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, + 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, + 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */, B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */, 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */, A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */, @@ -2487,7 +2516,6 @@ C116134A2983096D00777E7C /* Localizable.strings */, C11613472983096D00777E7C /* InfoPlist.strings */, 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */, - 14B1736428AED9EE006CCD7C /* Assets.xcassets */, 14B1736628AED9EE006CCD7C /* Info.plist */, ); path = Bootstrap; @@ -2581,8 +2609,11 @@ 897A5A9724C22DCE00C4E71D /* View Models */ = { isa = PBXGroup; children = ( + 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */, 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, + 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */, A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, + 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */, C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, 1D49795724E7289700948F05 /* ServicesViewModel.swift */, @@ -3311,9 +3342,11 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 14B1736528AED9EE006CCD7C /* Assets.xcassets in Resources */, C116134C2983096D00777E7C /* Localizable.strings in Resources */, + 147EFE8E2A8BCC5500272438 /* DefaultAssets.xcassets in Resources */, C11613492983096D00777E7C /* InfoPlist.strings in Resources */, + 147EFE902A8BCD8000272438 /* DerivedAssets.xcassets in Resources */, + 147EFE922A8BCD8A00272438 /* DerivedAssetsBase.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3623,6 +3656,7 @@ 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */, C1F2075C26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift in Sources */, B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, + 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */, C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, @@ -3636,22 +3670,27 @@ 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */, C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */, + 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */, C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */, 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */, C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */, + 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, + 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, + 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, + 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, @@ -3672,7 +3711,6 @@ 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, - 892D7C5123B54A15008A9656 /* CarbEntryViewController.swift in Sources */, B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, @@ -3684,6 +3722,7 @@ 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, + 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */, 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, @@ -3776,6 +3815,7 @@ 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, + 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, @@ -3785,6 +3825,7 @@ 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, + 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */, @@ -4770,7 +4811,6 @@ CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - CURRENT_PROJECT_VERSION = 101; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -4815,7 +4855,6 @@ CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - CURRENT_PROJECT_VERSION = 101; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index d9812eb13a..dac14ecfb4 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -447,7 +447,6 @@ - @@ -693,206 +692,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Contents.json new file mode 100644 index 0000000000..e910256d55 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Favorite Foods Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Favorite Foods Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Favorite Foods Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon.png b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon.png new file mode 100644 index 0000000000..794ae3f5a7 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon.png differ diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@2x.png b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@2x.png new file mode 100644 index 0000000000..3220f834e2 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@2x.png differ diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@3x.png b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@3x.png new file mode 100644 index 0000000000..0ace52c65e Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@3x.png differ diff --git a/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift index cb92136105..25173f92d8 100644 --- a/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift +++ b/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift @@ -10,6 +10,12 @@ import HealthKit import LoopCore import LoopKit +extension DeviceDataManager: CarbEntryViewModelDelegate { + var defaultAbsorptionTimes: LoopKit.CarbStore.DefaultAbsorptionTimes { + return carbStore.defaultAbsorptionTimes + } +} + extension DeviceDataManager: BolusEntryViewModelDelegate, ManualDoseViewModelDelegate { func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) { diff --git a/Loop/Extensions/EditMode.swift b/Loop/Extensions/EditMode.swift new file mode 100644 index 0000000000..b1ff303a43 --- /dev/null +++ b/Loop/Extensions/EditMode.swift @@ -0,0 +1,19 @@ +// +// EditMode.swift +// Loop +// +// Created by Noah Brauner on 7/13/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +extension EditMode { + var title: String { + self == .active ? "Done" : "Edit" + } + + mutating func toggle() { + self = self == .active ? .inactive : .active + } +} diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift index dae95c9611..4894dcc777 100644 --- a/Loop/Extensions/UserDefaults+Loop.swift +++ b/Loop/Extensions/UserDefaults+Loop.swift @@ -16,6 +16,7 @@ extension UserDefaults { case legacyServicesState = "com.loopkit.Loop.ServicesState" case loopNotRunningNotifications = "com.loopkit.Loop.loopNotRunningNotifications" case inFlightAutomaticDose = "com.loopkit.Loop.inFlightAutomaticDose" + case favoriteFoods = "com.loopkit.Loop.favoriteFoods" } var legacyPumpManagerRawValue: PumpManager.RawValue? { @@ -89,4 +90,23 @@ extension UserDefaults { } } } + + var favoriteFoods: [StoredFavoriteFood] { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.favoriteFoods.rawValue) as? Data else { + return [] + } + return (try? decoder.decode([StoredFavoriteFood].self, from: data)) ?? [] + } + set { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.favoriteFoods.rawValue) + } catch { + assertionFailure("Unable to encode stored favorite foods") + } + } + } } diff --git a/Loop/Info.plist b/Loop/Info.plist index d602518d3d..ddad5426ac 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -2,8 +2,6 @@ - NFCReaderUsageDescription - The app uses NFC to pair with diabetes devices. AppGroupIdentifier $(APP_GROUP_IDENTIFIER) AppStoreURL @@ -57,6 +55,8 @@ LoopLocalCacheDurationDays $(LOOP_LOCAL_CACHE_DURATION_DAYS) + NFCReaderUsageDescription + The app uses NFC to pair with diabetes devices. NSBluetoothAlwaysUsageDescription The app needs to use Bluetooth to send and receive data from your diabetes devices. NSBluetoothPeripheralUsageDescription diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 1a22bf71d8..083c41e203 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1017,13 +1017,13 @@ extension LoopDataManager { } } - if insulinEffect == nil { + if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { self.logger.debug("Recomputing insulin effects") updateGroup.enter() doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in switch result { case .failure(let error): - self.logger.error("%{public}@", String(describing: error)) + self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) self.insulinEffect = nil warnings.append(.fetchDataWarning(.insulinEffect(error: error))) case .success(let effects): @@ -1039,7 +1039,7 @@ extension LoopDataManager { doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: nil) { (result) -> Void in switch result { case .failure(let error): - self.logger.error("Could not fetch insulin effects: %{public}@", String(describing: error)) + self.logger.error("Could not fetch insulin effects including pending insulin: %{public}@", error.localizedDescription) self.insulinEffectIncludingPendingInsulin = nil warnings.append(.fetchDataWarning(.insulinEffectIncludingPendingInsulin(error: error))) case .success(let effects): diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 91a240714a..fc770192e9 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -5,7 +5,7 @@ // Copyright © 2017 LoopKit Authors. All rights reserved. // -import UIKit +import SwiftUI import HealthKit import Intents import LoopCore @@ -488,55 +488,44 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif return (allowEditing && carbStatuses[indexPath.row].entry.createdByCurrentApp) ? indexPath : nil } } - - // MARK: - Navigation - - override func restoreUserActivityState(_ activity: NSUserActivity) { - switch activity.activityType { - case NSUserActivity.newCarbEntryActivityType: - performSegue(withIdentifier: CarbEntryViewController.className, sender: activity) - default: - break - } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.row < carbStatuses.count else { return } + tableView.deselectRow(at: indexPath, animated: true) + + let originalCarbEntry = carbStatuses[indexPath.row].entry + + let viewModel = CarbEntryViewModel(delegate: deviceManager, originalCarbEntry: originalCarbEntry) + let carbEntryView = CarbEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.dismissAction, carbEditWasCanceled) + let hostingController = UIHostingController(rootView: carbEntryView) + hostingController.title = "Edit Carb Entry" + hostingController.navigationItem.largeTitleDisplayMode = .never + let leftBarButton = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(carbEditWasCanceled)) + hostingController.navigationItem.backBarButtonItem = leftBarButton + navigationController?.pushViewController(hostingController, animated: true) + } + + @objc func carbEditWasCanceled() { + navigationController?.popToViewController(self, animated: true) } + // MARK: - Navigation @IBAction func presentCarbEntryScreen() { - let navigationWrapper: UINavigationController if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) - navigationWrapper = UINavigationController(rootViewController: hostingController) + let navigationWrapper = UINavigationController(rootViewController: hostingController) hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + present(navigationWrapper, animated: true) } else { - let carbEntryViewController = UIStoryboard(name: "Main", bundle: Bundle(for: AppDelegate.self)).instantiateViewController(withIdentifier: "CarbEntryViewController") as! CarbEntryViewController - - carbEntryViewController.deviceManager = deviceManager - carbEntryViewController.defaultAbsorptionTimes = deviceManager.carbStore.defaultAbsorptionTimes - carbEntryViewController.preferredCarbUnit = deviceManager.carbStore.preferredUnit - navigationWrapper = UINavigationController(rootViewController: carbEntryViewController) + let viewModel = CarbEntryViewModel(delegate: deviceManager) + let carbEntryView = CarbEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) + let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) + present(hostingController, animated: true) } - self.present(navigationWrapper, animated: true) } - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - super.prepare(for: segue, sender: sender) - - switch segue.destination { - 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.carbStore.defaultAbsorptionTimes - vc.preferredCarbUnit = deviceManager.carbStore.preferredUnit - default: - break - } - } - - @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) {} } diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift deleted file mode 100644 index 92b7ef7be4..0000000000 --- a/Loop/View Controllers/CarbEntryViewController.swift +++ /dev/null @@ -1,786 +0,0 @@ -// -// 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: LoopChartsTableViewController, IdentifiableClass { - - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes? { - didSet { - if let times = defaultAbsorptionTimes { - orderedAbsorptionTimes = [times.fast, times.medium, times.slow] - } - } - } - - fileprivate var orderedAbsorptionTimes = [TimeInterval]() - - var preferredCarbUnit = HKUnit.gram() - - private var glucoseUnit: HKUnit { - return deviceManager.displayGlucosePreference.unit - } - - var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity - - var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity - - /// Entry configuration values. Must be set before presenting. - var absorptionTimePickerInterval = TimeInterval(minutes: 30) - - var maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime - - var maximumDateFutureInterval = TimeInterval(hours: 4) - - 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 lastEntryDate: Date? - - fileprivate func updateLastEntryDate() { lastEntryDate = Date() } - - fileprivate var quantity: HKQuantity? { - didSet { - if quantity != oldValue { - updateLastEntryDate() - } - updateContinueButtonEnabled() - } - } - - fileprivate var date = Date() { - didSet { - if date != oldValue { - updateLastEntryDate() - } - updateContinueButtonEnabled() - } - } - - fileprivate var foodType: String? { - didSet { - if foodType != oldValue { - updateLastEntryDate() - } - updateContinueButtonEnabled() - } - } - - fileprivate var absorptionTime: TimeInterval? { - didSet { - if absorptionTime != oldValue { - updateLastEntryDate() - } - updateContinueButtonEnabled() - } - } - - private var selectedDefaultAbsorptionTimeEmoji: String? - - fileprivate var absorptionTimeWasEdited = false - - fileprivate var usesCustomFoodType = false - - private var shouldBeginEditingQuantity = true - - private var shouldBeginEditingFoodType = false - - private var shouldDisplayMissedMealWarning = false { - didSet { - if shouldDisplayMissedMealWarning != oldValue { - if shouldDisplayOverrideEnabledWarning { - self.displayWarningRow(rowType: WarningRow.missedMeal, isAddingRow: shouldDisplayMissedMealWarning) - } else { - self.shouldDisplayWarning = shouldDisplayMissedMealWarning || shouldDisplayOverrideEnabledWarning - } - } - } - } - - private var shouldDisplayOverrideEnabledWarning = false { - didSet { - if shouldDisplayOverrideEnabledWarning != oldValue { - if shouldDisplayMissedMealWarning { - self.displayWarningRow(rowType: WarningRow.override, isAddingRow: shouldDisplayOverrideEnabledWarning) - } else { - self.shouldDisplayWarning = shouldDisplayOverrideEnabledWarning || shouldDisplayMissedMealWarning - } - } - } - } - - private var shouldDisplayWarning = false { - didSet { - if shouldDisplayWarning != oldValue { - self.displayWarning() - } - } - } - - var updatedCarbEntry: NewCarbEntry? { - if let lastEntryDate = lastEntryDate, - 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( - date: lastEntryDate, - quantity: quantity, - startDate: date, - foodType: foodType ?? selectedDefaultAbsorptionTimeEmoji, - absorptionTime: absorptionTime - ) - } 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: preferredCarbUnit) > 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: .primary, 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() - - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 44 - tableView.register(DateAndDurationTableViewCell.nib(), forCellReuseIdentifier: DateAndDurationTableViewCell.className) - tableView.register(DateAndDurationSteppableTableViewCell.nib(), forCellReuseIdentifier: DateAndDurationSteppableTableViewCell.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: DetailsRow.value.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayWarning))) as? DecimalTextFieldTableViewCell { - shouldBeginEditingQuantity = false - cell.textField.becomeFirstResponder() - } - - // check if warning should be displayed - updateDisplayOverrideEnabledWarning() - - // monitor loop updates - notificationObservers += [ - NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] _ in - self?.updateDisplayOverrideEnabledWarning() - } - ] - } - - 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! - - private func updateDisplayOverrideEnabledWarning() { - DispatchQueue.main.async { - if let managerSettings = self.deviceManager?.settings { - if !managerSettings.scheduleOverrideEnabled(at: Date()) { - self.shouldDisplayOverrideEnabledWarning = false - } else if let overrideSettings = managerSettings.scheduleOverride?.settings { - self.shouldDisplayOverrideEnabledWarning = overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 - } - } - } - } - - private func displayWarning() { - tableView.beginUpdates() - - if shouldDisplayWarning { - tableView.insertSections([Sections.warning.rawValue], with: .top) - } else { - tableView.deleteSections([Sections.warning.rawValue], with: .top) - } - - tableView.endUpdates() - } - - private func displayWarningRow(rowType: WarningRow, isAddingRow: Bool = true ) { - if shouldDisplayWarning { - tableView.beginUpdates() - - // If the missed meal warning is shown, use the positional index of the given row type. - let rowIndex = shouldDisplayMissedMealWarning ? rowType.rawValue : 0 - - if isAddingRow { - tableView.insertRows(at: [IndexPath(row: rowIndex, section: Sections.warning.rawValue)], with: UITableView.RowAnimation.top) - } else { - tableView.deleteRows(at: [IndexPath(row: rowIndex, section: Sections.warning.rawValue)], with: UITableView.RowAnimation.top) - } - - tableView.endUpdates() - } - } - - // MARK: - Table view data source - fileprivate enum Sections: Int, CaseIterable { - case warning - case details - - static func indexForDetailsSection(displayWarningSection: Bool) -> Int { - return displayWarningSection ? Sections.details.rawValue : Sections.details.rawValue - 1 - } - - static func numberOfSections(displayWarningSection: Bool) -> Int { - return displayWarningSection ? Sections.allCases.count : Sections.allCases.count - 1 - } - - static func section(for indexPath: IndexPath, displayWarningSection: Bool) -> Int { - return displayWarningSection ? indexPath.section : indexPath.section + 1 - } - - static func numberOfRows(for section: Int, displayMissedMealWarning: Bool, displayOverrideWarning: Bool) -> Int { - if section == Sections.warning.rawValue && (displayMissedMealWarning || displayOverrideWarning) { - return displayMissedMealWarning && displayOverrideWarning ? WarningRow.allCases.count : WarningRow.allCases.count - 1 - } - - return DetailsRow.allCases.count - } - - static func footer(for section: Int, displayWarningSection: Bool) -> String? { - if section == Sections.warning.rawValue && displayWarningSection { - return nil - } - - 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") - } - - static func headerHeight(for section: Int, displayWarningSection: Bool) -> CGFloat { - return 8 - } - - static func footerHeight(for section: Int, displayWarningSection: Bool) -> CGFloat { - if section == Sections.warning.rawValue && displayWarningSection { - return 1 - } - - return UITableView.automaticDimension - } - } - - fileprivate enum DetailsRow: Int, CaseIterable { - case value - case date - case foodType - case absorptionTime - } - - fileprivate enum WarningRow: Int, CaseIterable { - case missedMeal - case override - } - - override func numberOfSections(in tableView: UITableView) -> Int { - return Sections.numberOfSections(displayWarningSection: shouldDisplayWarning) - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Sections.numberOfRows(for: section, displayMissedMealWarning: shouldDisplayMissedMealWarning, displayOverrideWarning: shouldDisplayOverrideEnabledWarning) - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch Sections(rawValue: Sections.section(for: indexPath, displayWarningSection: shouldDisplayWarning))! { - case .warning: - let cell: UITableViewCell - // if no missed meal warning should be shown OR if the given indexPath is for the override warning row, return the override warning cell. - if !shouldDisplayMissedMealWarning || WarningRow(rawValue: indexPath.row)! == .override { - if let existingCell = tableView.dequeueReusableCell(withIdentifier: "CarbEntryOverrideEnabledWarningCell") { - cell = existingCell - } else { - cell = UITableViewCell(style: .default, reuseIdentifier: "CarbEntryOverrideEnabledWarningCell") - } - - cell.imageView?.image = UIImage(systemName: "exclamationmark.triangle.fill") - cell.imageView?.tintColor = .warning - cell.textLabel?.numberOfLines = 0 - cell.textLabel?.text = NSLocalizedString("An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override.", comment: "Warning to ensure the carb entry is accurate during an override") - cell.textLabel?.font = UIFont.preferredFont(forTextStyle: .caption1) - cell.textLabel?.textColor = .secondaryLabel - cell.isUserInteractionEnabled = false - } else { - if let existingCell = tableView.dequeueReusableCell(withIdentifier: "CarbEntryAccuracyWarningCell") { - cell = existingCell - } else { - cell = UITableViewCell(style: .default, reuseIdentifier: "CarbEntryAccuracyWarningCell") - } - - cell.imageView?.image = UIImage(systemName: "exclamationmark.triangle.fill") - cell.imageView?.tintColor = .destructive - cell.textLabel?.numberOfLines = 0 - cell.textLabel?.text = NSLocalizedString("Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an missed meal notification") - cell.textLabel?.font = UIFont.preferredFont(forTextStyle: .caption1) - cell.textLabel?.textColor = .secondaryLabel - cell.isUserInteractionEnabled = false - } - return cell - case .details: - switch DetailsRow(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: preferredCarbUnit)) - } - cell.textField.isEnabled = isSampleEditable - cell.unitLabel?.text = String(describing: preferredCarbUnit) - cell.delegate = self - - return cell - case .date: - let cell = tableView.dequeueReusableCell(withIdentifier: DateAndDurationSteppableTableViewCell.className) as! DateAndDurationSteppableTableViewCell - - cell.titleLabel.text = NSLocalizedString("Time", comment: "Title of the carb entry date picker cell") - cell.datePicker.isEnabled = isSampleEditable - cell.datePicker.datePickerMode = .dateAndTime - #if swift(>=5.2) - if #available(iOS 14.0, *) { - cell.datePicker.preferredDatePickerStyle = .wheels - } - #endif - cell.datePicker.maximumDate = date.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) - cell.datePicker.minimumDate = date.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) - 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 Sections(rawValue: Sections.section(for: indexPath, displayWarningSection: shouldDisplayWarning)) { - case .details: - switch DetailsRow(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 - } - default: - break - } - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - return Sections.footer(for: section, displayWarningSection: shouldDisplayWarning) - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return Sections.headerHeight(for: section, displayWarningSection: shouldDisplayWarning) - } - - override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return Sections.footerHeight(for: section, displayWarningSection: shouldDisplayWarning) - } - - // 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: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayWarning))], with: .none) - default: - break - } - - tableView.endUpdates() - tableView.deselectRow(at: indexPath, animated: true) - } - - // MARK: - Navigation - - override func restoreUserActivityState(_ activity: NSUserActivity) { - if let entry = activity.newCarbEntry { - lastEntryDate = entry.date - 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 - } - - if activity.entryisMissedMeal { - shouldDisplayMissedMealWarning = true - } - } - } - - @objc private func continueButtonPressed() { - tableView.endEditing(true) - guard validateInput() else { - return - } - continueToBolus() - } - - private func continueToBolus() { - - guard let updatedEntry = updatedCarbEntry else { - return - } - - let viewModel = BolusEntryViewModel( - delegate: deviceManager, - screenWidth: UIScreen.main.bounds.width, - originalCarbEntry: originalCarbEntry, - potentialCarbEntry: updatedEntry, - selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji - ) - Task { - await viewModel.generateRecommendationAndStartObserving() - } - - viewModel.analyticsServicesManager = deviceManager.analyticsServicesManager - - let bolusEntryView = BolusEntryView(viewModel: viewModel).environmentObject(deviceManager.displayGlucosePreference) - - // After confirming a bolus, pop back to this controller's predecessor, i.e. all the way back out of the carb flow. - let predecessorViewControllerType = (navigationController?.viewControllers.dropLast().last).map { type(of: $0) } ?? UIViewController.self - let hostingController = DismissibleHostingController( - rootView: bolusEntryView, - dismissalMode: originalCarbEntry == nil ? .modalDismiss : .pop(to: predecessorViewControllerType) - ) - show(hostingController, sender: footerView.primaryButton) - deviceManager.analyticsServicesManager.didDisplayBolusScreen() - } - - private func validateInput() -> Bool { - guard let absorptionTime = absorptionTime ?? defaultAbsorptionTimes?.medium else { - return false - } - guard absorptionTime <= maxAbsorptionTime else { - showAbsorptionTimeValidationWarning(for: self, maxAbsorptionTime: maxAbsorptionTime) - return false - } - - guard let quantity = quantity, quantity.doubleValue(for: preferredCarbUnit) > 0 else { return false } - guard quantity.compare(maxCarbEntryQuantity) != .orderedDescending else { - showMaxQuantityValidationWarning(for: self, maxQuantityGrams: maxCarbEntryQuantity.doubleValue(for: .gram())) - return false - } - - let enteredGrams = quantity.doubleValue(for: .gram()) - - if (enteredGrams > warningCarbEntryQuantity.doubleValue(for: .gram())) { - showWarningQuantityValidationWarning(for: self, enteredGrams: enteredGrams) { - self.continueToBolus() - } - return false - } - return true - } - - private func updateContinueButtonEnabled() { - let hasValidQuantity = quantity != nil && quantity!.doubleValue(for: preferredCarbUnit) > 0 - let haveChangesBeenMade = updatedCarbEntry != nil - - let readyToContinue = hasValidQuantity && haveChangesBeenMade - - footerView.primaryButton.isEnabled = readyToContinue - navigationItem.rightBarButtonItem?.isEnabled = readyToContinue - } - - // Alerts - private lazy var dismissActionTitle = NSLocalizedString("com.loudnate.LoopKit.errorAlertActionTitle", value: "OK", comment: "The title of the action used to dismiss an error alert") - - public func showAbsorptionTimeValidationWarning(for viewController: UIViewController, maxAbsorptionTime: TimeInterval) { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.minute] - formatter.unitsStyle = .full - - let message = String( - format: NSLocalizedString("The maximum absorption time is %@", comment: "Alert body displayed absorption time greater than max (1: maximum absorption time)"), - formatter.string(from: maxAbsorptionTime) ?? String(describing: maxAbsorptionTime)) - let validationTitle = NSLocalizedString("Maximum Duration Exceeded", comment: "Alert title when maximum duration exceeded.") - let alert = UIAlertController(title: validationTitle, message: message, preferredStyle: .alert) - - let action = UIAlertAction(title: dismissActionTitle, style: .default) - alert.addAction(action) - alert.preferredAction = action - - viewController.present(alert, animated: true) - } - - public func showWarningQuantityValidationWarning(for viewController: UIViewController, enteredGrams: Double, didConfirm: @escaping () -> Void) { - let warningTitle = NSLocalizedString("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered") - - let message = String( - format: NSLocalizedString("Did you intend to enter %1$@ grams as the amount of carbohydrates for this meal?", comment: "Alert body when entered carbohydrates is greater than threshold (1: entered quantity in grams)"), - NumberFormatter.localizedString(from: NSNumber(value: enteredGrams), number: .none) - ) - let alert = UIAlertController(title: warningTitle, message: message, preferredStyle: .alert) - - let editButtonText = NSLocalizedString("No, edit amount", comment: "The title of the action used when rejecting the the amount of carbohydrates entered.") - let editAction = UIAlertAction(title: editButtonText, style: .default) - alert.addAction(editAction) - - let confirmButtonText = NSLocalizedString("Yes", comment: "The title of the action used when confirming entered amount of carbohydrates.") - let confirm = UIAlertAction(title: confirmButtonText, style: .default) {_ in - didConfirm(); - } - alert.addAction(confirm) - alert.preferredAction = confirm - - viewController.present(alert, animated: true) - } - - public func showMaxQuantityValidationWarning(for viewController: UIViewController, maxQuantityGrams: Double) { - let errorTitle = NSLocalizedString("Input Maximum Exceeded", comment: "Title of the alert when carb input maximum was exceeded.") - let message = String( - format: NSLocalizedString("The maximum allowed amount is %@ grams.", comment: "Alert body displayed for quantity greater than max (1: maximum quantity in grams)"), - NumberFormatter.localizedString(from: NSNumber(value: maxQuantityGrams), number: .none) - ) - let alert = UIAlertController(title: errorTitle, message: message, preferredStyle: .alert) - - let action = UIAlertAction(title: dismissActionTitle, style: .default) - alert.addAction(action) - alert.preferredAction = action - - viewController.present(alert, animated: true) - } -} - - -extension CarbEntryViewController: TextFieldTableViewCellDelegate { - func textFieldTableViewCellDidBeginEditing(_ cell: TextFieldTableViewCell) { - // Collapse any date picker cells to save space - tableView.beginUpdates() - hideDatePickerCells() - tableView.endUpdates() - } - - func textFieldTableViewCellDidChangeEditing(_ cell: TextFieldTableViewCell) { - guard let row = tableView.indexPath(for: cell)?.row else { return } - - switch DetailsRow(rawValue: row) { - case .value?: - if let cell = cell as? DecimalTextFieldTableViewCell, let number = cell.number { - quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: number.doubleValue) - } else { - quantity = nil - } - case .foodType?: - foodType = cell.textField.text - default: - break - } - } - - func textFieldTableViewCellDidEndEditing(_ cell: TextFieldTableViewCell) { - textFieldTableViewCellDidChangeEditing(cell) - } -} - -extension CarbEntryViewController: DatePickerTableViewCellDelegate { - func datePickerTableViewCellDidUpdateDate(_ cell: DatePickerTableViewCell) { - guard let row = tableView.indexPath(for: cell)?.row else { return } - - switch DetailsRow(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: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayWarning))], with: .fade) - tableView.endUpdates() - } - - if let absorptionTime = absorptionTime { - self.absorptionTime = absorptionTime - - if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.absorptionTime.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayWarning))) as? DateAndDurationTableViewCell { - cell.duration = absorptionTime - } - } - - selectedDefaultAbsorptionTimeEmoji = cell.selectedEmoji - } -} - - -extension CarbEntryViewController: EmojiInputControllerDelegate { - func emojiInputControllerDidAdvanceToStandardInputMode(_ controller: EmojiInputController) { - if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayWarning))) 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 - } - - if absorptionTime == nil { - // only adjust the absorption time if it wasn't already set. - absorptionTime = orderedAbsorptionTimes[section] - - if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.absorptionTime.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayWarning))) as? DateAndDurationTableViewCell { - cell.duration = orderedAbsorptionTimes[section] - } - } - } -} - -extension DateAndDurationTableViewCell: NibLoadable {} - -extension DateAndDurationSteppableTableViewCell: NibLoadable {} diff --git a/Loop/View Controllers/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift index 7a4b2facf9..87b04d5e93 100644 --- a/Loop/View Controllers/RootNavigationController.swift +++ b/Loop/View Controllers/RootNavigationController.swift @@ -22,7 +22,7 @@ class RootNavigationController: UINavigationController { case .carbEntry: statusTableViewController.presentCarbEntryScreen(nil) case .preMeal: - statusTableViewController.presentPreMealMode() + statusTableViewController.togglePreMealMode() case .bolus: statusTableViewController.presentBolusScreen() case .customPresets: @@ -40,24 +40,6 @@ class RootNavigationController: UINavigationController { if viewControllers.count > 1 { popToRootViewController(animated: false) } - case NSUserActivity.newCarbEntryActivityType: - if let navVC = presentedViewController as? UINavigationController { - if let carbVC = navVC.topViewController as? CarbEntryViewController { - carbVC.restoreUserActivityState(activity) - return - } else { - dismiss(animated: false, completion: nil) - } - } - - if let carbVC = topViewController as? CarbAbsorptionViewController { - carbVC.restoreUserActivityState(activity) - return - } else if viewControllers.count > 1 { - popToRootViewController(animated: false) - } - - fallthrough default: statusTableViewController.restoreUserActivityState(activity) } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 0ab5d294fb..74b49790aa 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -270,11 +270,11 @@ final class StatusTableViewController: LoopChartsTableViewController { private func setupToolbarItems() { let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) let carbs = UIBarButtonItem(image: UIImage(named: "carbs"), style: .plain, target: self, action: #selector(userTappedAddCarbs)) - let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) let bolus = UIBarButtonItem(image: UIImage(named: "bolus"), style: .plain, target: self, action: #selector(presentBolusScreen)) - let workout = createWorkoutButtonItem(selected: false, isEnabled: true) let settings = UIBarButtonItem(image: UIImage(named: "settings"), style: .plain, target: self, action: #selector(onSettingsTapped)) + let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) + let workout = createWorkoutButtonItem(selected: false, isEnabled: true) toolbarItems = [ carbs, space, @@ -287,20 +287,21 @@ final class StatusTableViewController: LoopChartsTableViewController { settings ] } - + private func updateToolbarItems() { let isPumpOnboarded = onboardingManager.isComplete || deviceManager.pumpManager?.isOnboarded == true toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") toolbarItems![0].isEnabled = isPumpOnboarded toolbarItems![0].tintColor = UIColor.carbTintColor - toolbarItems![2].isEnabled = isPumpOnboarded && (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) toolbarItems![4].accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") toolbarItems![4].isEnabled = isPumpOnboarded toolbarItems![4].tintColor = UIColor.insulinTintColor - toolbarItems![6].isEnabled = isPumpOnboarded toolbarItems![8].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") toolbarItems![8].tintColor = UIColor.secondaryLabel + + toolbarItems![2] = createPreMealButtonItem(selected: preMealMode == true && preMealModeAllowed, isEnabled: preMealModeAllowed) + toolbarItems![6] = createWorkoutButtonItem(selected: workoutMode == true && workoutModeAllowed, isEnabled: workoutModeAllowed) } public var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil { @@ -522,7 +523,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } } - updatePreMealModeAvailability(automaticDosingEnabled: automaticDosingEnabled) + updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) if deviceManager.loopManager.settings.preMealTargetRange == nil { preMealMode = nil @@ -831,15 +832,21 @@ final class StatusTableViewController: LoopChartsTableViewController { guard oldValue != preMealMode else { return } - updatePreMealModeAvailability(automaticDosingEnabled: automaticDosingStatus.automaticDosingEnabled) + updatePresetModeAvailability(automaticDosingEnabled: automaticDosingStatus.automaticDosingEnabled) } } + private lazy var preMealModeAllowed: Bool = { + onboardingManager.isComplete && + (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && deviceManager.loopManager.settings.preMealTargetRange != nil + }() - private func updatePreMealModeAvailability(automaticDosingEnabled: Bool) { - let allowed = onboardingManager.isComplete && + private func updatePresetModeAvailability(automaticDosingEnabled: Bool) { + preMealModeAllowed = onboardingManager.isComplete && (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) && deviceManager.loopManager.settings.preMealTargetRange != nil - toolbarItems![2] = createPreMealButtonItem(selected: preMealMode ?? false && allowed, isEnabled: allowed) + workoutModeAllowed = onboardingManager.isComplete && workoutMode != nil + updateToolbarItems() } private var workoutMode: Bool? = nil { @@ -847,15 +854,13 @@ final class StatusTableViewController: LoopChartsTableViewController { guard oldValue != workoutMode else { return } - - if let workoutMode = workoutMode { - let allowed = onboardingManager.isComplete - toolbarItems![6] = createWorkoutButtonItem(selected: workoutMode, isEnabled: allowed) - } else { - toolbarItems![6].isEnabled = false - } + workoutModeAllowed = workoutMode != nil && onboardingManager.isComplete + updateToolbarItems() } } + private lazy var workoutModeAllowed: Bool = { + workoutMode != nil && onboardingManager.isComplete + }() // MARK: - Table view data source @@ -1363,18 +1368,17 @@ final class StatusTableViewController: LoopChartsTableViewController { let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) navigationWrapper = UINavigationController(rootViewController: hostingController) hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + present(navigationWrapper, animated: true) } else { - let carbEntryViewController = UIStoryboard(name: "Main", bundle: Bundle(for: AppDelegate.self)).instantiateViewController(withIdentifier: "CarbEntryViewController") as! CarbEntryViewController - - carbEntryViewController.deviceManager = deviceManager - carbEntryViewController.defaultAbsorptionTimes = deviceManager.carbStore.defaultAbsorptionTimes - carbEntryViewController.preferredCarbUnit = deviceManager.carbStore.preferredUnit - if let activity = activity { - carbEntryViewController.restoreUserActivityState(activity) + let viewModel = CarbEntryViewModel(delegate: deviceManager) + if let activity { + viewModel.restoreUserActivityState(activity) } - navigationWrapper = UINavigationController(rootViewController: carbEntryViewController) + let carbEntryView = CarbEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) + let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) + present(hostingController, animated: true) } - present(navigationWrapper, animated: true) deviceManager.analyticsServicesManager.didDisplayCarbEntryScreen() } @@ -1404,7 +1408,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func createPreMealButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { - let item = UIBarButtonItem(image: UIImage.preMealImage(selected: selected), style: .plain, target: self, action: #selector(togglePreMealMode(_:))) + let item = UIBarButtonItem(image: UIImage.preMealImage(selected: selected), style: .plain, target: self, action: #selector(premealButtonTapped(_:))) item.accessibilityLabel = NSLocalizedString("Pre-Meal Targets", comment: "The label of the pre-meal mode toggle button") if selected { @@ -1419,7 +1423,7 @@ final class StatusTableViewController: LoopChartsTableViewController { return item } - + private func createWorkoutButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { let item = UIBarButtonItem(image: UIImage.workoutImage(selected: selected), style: .plain, target: self, action: #selector(toggleWorkoutMode(_:))) item.accessibilityLabel = NSLocalizedString("Workout Targets", comment: "The label of the workout mode toggle button") @@ -1436,8 +1440,12 @@ final class StatusTableViewController: LoopChartsTableViewController { return item } + + @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { + togglePreMealMode(confirm: false) + } - func presentPreMealMode(confirm: Bool = true) { + func togglePreMealMode(confirm: Bool = true) { if preMealMode == true { if confirm { let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) @@ -1454,35 +1462,35 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } else { - let vc = UIAlertController(premealDurationSelectionHandler: { duration in - let startDate = Date() + presentPreMealModeAlertController() + } + } + + func presentPreMealModeAlertController() { + let vc = UIAlertController(premealDurationSelectionHandler: { duration in + let startDate = Date() - guard self.workoutMode != true else { - // allow cell animation when switching between presets + guard self.workoutMode != true else { + // allow cell animation when switching between presets + self.deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } + settings.enablePreMealOverride(at: startDate, for: duration) } - return } + return + } - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } - }) + self.deviceManager.loopManager.mutateSettings { settings in + settings.enablePreMealOverride(at: startDate, for: duration) + } + }) - present(vc, animated: true, completion: nil) - } + present(vc, animated: true, completion: nil) } - @IBAction func togglePreMealMode(_ sender: UIBarButtonItem) { - presentPreMealMode(confirm: false) - } - func presentCustomPresets(confirm: Bool = true) { if workoutMode == true { if confirm { @@ -1503,30 +1511,34 @@ final class StatusTableViewController: LoopChartsTableViewController { if FeatureFlags.sensitivityOverridesEnabled { performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![6]) } else { - let vc = UIAlertController(workoutDurationSelectionHandler: { duration in - let startDate = Date() - - guard self.preMealMode != true else { - // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } - } - return - } + presentWorkoutModeAlertController() + } + } + } + + func presentWorkoutModeAlertController() { + let vc = UIAlertController(workoutDurationSelectionHandler: { duration in + let startDate = Date() + guard self.preMealMode != true else { + // allow cell animation when switching between presets + self.deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride(matching: .preMeal) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.deviceManager.loopManager.mutateSettings { settings in settings.enableLegacyWorkoutOverride(at: startDate, for: duration) } - }) + } + return + } - present(vc, animated: true, completion: nil) + self.deviceManager.loopManager.mutateSettings { settings in + settings.enableLegacyWorkoutOverride(at: startDate, for: duration) } - } + }) + + present(vc, animated: true, completion: nil) } @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { @@ -1625,7 +1637,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func automaticDosingStatusChanged(_ automaticDosingEnabled: Bool) { - updatePreMealModeAvailability(automaticDosingEnabled: automaticDosingEnabled) + updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) hudView?.loopCompletionHUD.loopIconClosed = automaticDosingEnabled hudView?.loopCompletionHUD.closedLoopDisallowedLocalizedDescription = deviceManager.closedLoopDisallowedLocalizedDescription } diff --git a/Loop/View Models/AddEditFavoriteFoodViewModel.swift b/Loop/View Models/AddEditFavoriteFoodViewModel.swift new file mode 100644 index 0000000000..5bd6eb8775 --- /dev/null +++ b/Loop/View Models/AddEditFavoriteFoodViewModel.swift @@ -0,0 +1,109 @@ +// +// AddEditFavoriteFoodViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit + +final class AddEditFavoriteFoodViewModel: ObservableObject { + enum Alert: Identifiable { + var id: Self { + return self + } + + case maxQuantityExceded + case warningQuantityValidation + } + + @Published var name = "" + + @Published var carbsQuantity: Double? = nil + var preferredCarbUnit = HKUnit.gram() + var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity + var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity + + @Published var foodType = "" + + @Published var absorptionTime: TimeInterval + let minAbsorptionTime = LoopConstants.minCarbAbsorptionTime + let maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime + var absorptionRimesRange: ClosedRange { + return minAbsorptionTime...maxAbsorptionTime + } + + @Published var alert: AddEditFavoriteFoodViewModel.Alert? + + private let onSave: (NewFavoriteFood) -> () + + init(originalFavoriteFood: StoredFavoriteFood?, onSave: @escaping (NewFavoriteFood) -> ()) { + self.onSave = onSave + if let food = originalFavoriteFood { + self.originalFavoriteFood = food + self.name = food.name + self.carbsQuantity = food.carbsQuantity.doubleValue(for: preferredCarbUnit) + self.foodType = food.foodType + self.absorptionTime = food.absorptionTime + } + else { + self.absorptionTime = .hours(3) + } + } + + init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> ()) { + self.onSave = onSave + self.carbsQuantity = carbsQuantity + self.foodType = foodType + self.absorptionTime = absorptionTime + } + + var originalFavoriteFood: StoredFavoriteFood? + var updatedFavoriteFood: NewFavoriteFood? { + if let quantity = carbsQuantity, quantity != 0, name != "", foodType != "" { + if let o = originalFavoriteFood, o.name == name, o.carbsQuantity.doubleValue(for: preferredCarbUnit) == carbsQuantity && o.foodType == foodType && o.absorptionTime == absorptionTime { + return nil // No changes were made + } + + return NewFavoriteFood( + name: name, + carbsQuantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + foodType: foodType, + absorptionTime: absorptionTime + ) + } + else { + return nil + } + } + + func save() { + guard let updatedFavoriteFood, absorptionTime <= maxAbsorptionTime else { return } + + guard let carbsQuantity, carbsQuantity > 0 else { return } + let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { + self.alert = .maxQuantityExceded + return + } + else if quantity.compare(warningCarbEntryQuantity) == .orderedDescending { + self.alert = .warningQuantityValidation + return + } + + onSave(updatedFavoriteFood) + } + + func clearAlertAndSave() { + guard let updatedFavoriteFood else { return } + self.alert = nil + onSave(updatedFavoriteFood) + } + + func clearAlert() { + self.alert = nil + } +} diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index f572e35dfd..a86f20e0cc 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -162,7 +162,7 @@ final class BolusEntryViewModel: ObservableObject { // MARK: - Initialization init( - delegate: BolusEntryViewModelDelegate, + delegate: BolusEntryViewModelDelegate?, now: @escaping () -> Date = { Date() }, screenWidth: CGFloat, debounceIntervalMilliseconds: Int = 400, diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift new file mode 100644 index 0000000000..d04ddba78e --- /dev/null +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -0,0 +1,317 @@ +// +// CarbEntryViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/21/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit +import Combine + +protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { + var analyticsServicesManager: AnalyticsServicesManager { get } + var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } +} + +final class CarbEntryViewModel: ObservableObject { + enum Alert: Identifiable { + var id: Self { + return self + } + + case maxQuantityExceded + case warningQuantityValidation + } + + enum Warning: Identifiable { + var id: Self { + return self + } + + var priority: Int { + switch self { + case .entryIsMissedMeal: + return 1 + case .overrideInProgress: + return 2 + } + } + + case entryIsMissedMeal + case overrideInProgress + } + + @Published var alert: CarbEntryViewModel.Alert? + @Published var warnings: Set = [] + + @Published var bolusViewModel: BolusEntryViewModel? + + let shouldBeginEditingQuantity: Bool + + @Published var carbsQuantity: Double? = nil + var preferredCarbUnit = HKUnit.gram() + var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity + var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity + + @Published var time = Date() + private var date = Date() + var minimumDate: Date { + get { date.addingTimeInterval(.hours(-12)) } + } + var maximumDate: Date { + get { date.addingTimeInterval(.hours(1)) } + } + + @Published var foodType = "" + @Published var selectedDefaultAbsorptionTimeEmoji: String = "" + @Published var usesCustomFoodType = false + @Published var absorptionTimeWasEdited = false // if true, selecting an emoji will not alter the absorption time + private var absorptionEditIsProgrammatic = false // needed for when absorption time is changed due to favorite food selection, so that absorptionTimeWasEdited does not get set to true + + @Published var absorptionTime: TimeInterval + let defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes + let minAbsorptionTime = LoopConstants.minCarbAbsorptionTime + let maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime + var absorptionRimesRange: ClosedRange { + return minAbsorptionTime...maxAbsorptionTime + } + + @Published var favoriteFoods = UserDefaults.standard.favoriteFoods + @Published var selectedFavoriteFoodIndex = -1 + + weak var delegate: CarbEntryViewModelDelegate? + + private lazy var cancellables = Set() + + /// Initalizer for when`CarbEntryView` is presented from the home screen + init(delegate: CarbEntryViewModelDelegate) { + self.delegate = delegate + self.absorptionTime = delegate.defaultAbsorptionTimes.medium + self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes + self.shouldBeginEditingQuantity = true + + observeAbsorptionTimeChange() + observeFavoriteFoodChange() + observeFavoriteFoodIndexChange() + observeLoopUpdates() + } + + /// Initalizer for when`CarbEntryView` has an entry to edit + init(delegate: CarbEntryViewModelDelegate, originalCarbEntry: StoredCarbEntry) { + self.delegate = delegate + self.originalCarbEntry = originalCarbEntry + self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes + + self.carbsQuantity = originalCarbEntry.quantity.doubleValue(for: preferredCarbUnit) + self.time = originalCarbEntry.startDate + self.foodType = originalCarbEntry.foodType ?? "" + self.absorptionTime = originalCarbEntry.absorptionTime ?? .hours(3) + self.absorptionTimeWasEdited = true + self.usesCustomFoodType = true + self.shouldBeginEditingQuantity = false + + observeLoopUpdates() + } + + var originalCarbEntry: StoredCarbEntry? = nil + private var favoriteFood: FavoriteFood? = nil + + private var updatedCarbEntry: NewCarbEntry? { + if let quantity = carbsQuantity, quantity != 0 { + if let o = originalCarbEntry, o.quantity.doubleValue(for: preferredCarbUnit) == quantity && o.startDate == time && o.foodType == foodType && o.absorptionTime == absorptionTime { + return nil // No changes were made + } + + return NewCarbEntry( + date: date, + quantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + startDate: time, + foodType: usesCustomFoodType ? foodType : selectedDefaultAbsorptionTimeEmoji, + absorptionTime: absorptionTime + ) + } + else { + return nil + } + } + + var saveFavoriteFoodButtonDisabled: Bool { + get { + if let carbsQuantity, 0...maxCarbEntryQuantity.doubleValue(for: preferredCarbUnit) ~= carbsQuantity, foodType != "", selectedFavoriteFoodIndex == -1 { + return false + } + return true + } + } + + var continueButtonDisabled: Bool { + get { updatedCarbEntry == nil } + } + + // MARK: - Continue to Bolus and Carb Quantity Warnings + func continueToBolus() { + guard updatedCarbEntry != nil else { + return + } + + validateInputAndContinue() + } + + private func validateInputAndContinue() { + guard absorptionTime <= maxAbsorptionTime else { + return + } + + guard let carbsQuantity, carbsQuantity > 0 else { return } + let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { + self.alert = .maxQuantityExceded + return + } + else if quantity.compare(warningCarbEntryQuantity) == .orderedDescending, selectedFavoriteFoodIndex == -1 { + self.alert = .warningQuantityValidation + return + } + + Task { @MainActor in + setBolusViewModel() + } + } + + @MainActor private func setBolusViewModel() { + let viewModel = BolusEntryViewModel( + delegate: delegate, + screenWidth: UIScreen.main.bounds.width, + originalCarbEntry: originalCarbEntry, + potentialCarbEntry: updatedCarbEntry, + selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji + ) + Task { + await viewModel.generateRecommendationAndStartObserving() + } + + viewModel.analyticsServicesManager = delegate?.analyticsServicesManager + bolusViewModel = viewModel + + delegate?.analyticsServicesManager.didDisplayBolusScreen() + } + + func clearAlert() { + self.alert = nil + } + + func clearAlertAndContinueToBolus() { + self.alert = nil + Task { @MainActor in + setBolusViewModel() + } + } + + // MARK: - Favorite Foods + func onFavoriteFoodSave(_ food: NewFavoriteFood) { + let newStoredFood = StoredFavoriteFood(name: food.name, carbsQuantity: food.carbsQuantity, foodType: food.foodType, absorptionTime: food.absorptionTime) + favoriteFoods.append(newStoredFood) + selectedFavoriteFoodIndex = favoriteFoods.count - 1 + } + + private func observeFavoriteFoodIndexChange() { + $selectedFavoriteFoodIndex + .receive(on: RunLoop.main) + .dropFirst() + .sink { [weak self] index in + self?.favoriteFoodSelected(at: index) + } + .store(in: &cancellables) + } + + private func observeFavoriteFoodChange() { + $favoriteFoods + .dropFirst() + .removeDuplicates() + .sink { newValue in + UserDefaults.standard.favoriteFoods = newValue + } + .store(in: &cancellables) + } + + private func favoriteFoodSelected(at index: Int) { + self.absorptionEditIsProgrammatic = true + if index == -1 { + self.carbsQuantity = 0 + self.foodType = "" + self.absorptionTime = defaultAbsorptionTimes.medium + self.absorptionTimeWasEdited = false + self.usesCustomFoodType = false + } + else { + let food = favoriteFoods[index] + self.carbsQuantity = food.carbsQuantity.doubleValue(for: preferredCarbUnit) + self.foodType = food.foodType + self.absorptionTime = food.absorptionTime + self.absorptionTimeWasEdited = true + self.usesCustomFoodType = true + } + } + + // MARK: - Utility + func restoreUserActivityState(_ activity: NSUserActivity) { + if let entry = activity.newCarbEntry { + time = entry.date + carbsQuantity = entry.quantity.doubleValue(for: preferredCarbUnit) + + if let foodType = entry.foodType { + self.foodType = foodType + usesCustomFoodType = true + } + + if let absorptionTime = entry.absorptionTime { + self.absorptionTime = absorptionTime + absorptionTimeWasEdited = true + } + + if activity.entryisMissedMeal { + warnings.insert(.entryIsMissedMeal) + } + } + } + + private func observeLoopUpdates() { + self.checkIfOverrideEnabled() + NotificationCenter.default + .publisher(for: .LoopDataUpdated) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.checkIfOverrideEnabled() + } + .store(in: &cancellables) + } + + private func checkIfOverrideEnabled() { + if let managerSettings = delegate?.settings { + if let overrideSettings = managerSettings.scheduleOverride?.settings, overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 { + self.warnings.insert(.overrideInProgress) + } + else { + self.warnings.remove(.overrideInProgress) + } + } + } + + private func observeAbsorptionTimeChange() { + $absorptionTime + .receive(on: RunLoop.main) + .dropFirst() + .sink { [weak self] _ in + if self?.absorptionEditIsProgrammatic == true { + self?.absorptionEditIsProgrammatic = false + } + else { + self?.absorptionTimeWasEdited = true + } + } + .store(in: &cancellables) + } +} diff --git a/Loop/View Models/FavoriteFoodsViewModel.swift b/Loop/View Models/FavoriteFoodsViewModel.swift new file mode 100644 index 0000000000..48934d1c10 --- /dev/null +++ b/Loop/View Models/FavoriteFoodsViewModel.swift @@ -0,0 +1,83 @@ +// +// FavoriteFoodsViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/27/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import LoopKit +import Combine + +final class FavoriteFoodsViewModel: ObservableObject { + @Published var favoriteFoods = UserDefaults.standard.favoriteFoods + @Published var selectedFood: StoredFavoriteFood? + + @Published var isDetailViewActive = false + @Published var isEditViewActive = false + @Published var isAddViewActive = false + + var preferredCarbUnit = HKUnit.gram() + lazy var carbFormatter = QuantityFormatter(for: preferredCarbUnit) + lazy var absorptionTimeFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + return formatter + }() + + private lazy var cancellables = Set() + + init() { + observeFavoriteFoodChange() + } + + func onFoodSave(_ newFood: NewFavoriteFood) { + if isAddViewActive { + let newStoredFood = StoredFavoriteFood(name: newFood.name, carbsQuantity: newFood.carbsQuantity, foodType: newFood.foodType, absorptionTime: newFood.absorptionTime) + withAnimation { + favoriteFoods.append(newStoredFood) + } + isAddViewActive = false + } + else if var selectedFood, let selectedFooxIndex = favoriteFoods.firstIndex(of: selectedFood) { + selectedFood.name = newFood.name + selectedFood.carbsQuantity = newFood.carbsQuantity + selectedFood.foodType = newFood.foodType + selectedFood.absorptionTime = newFood.absorptionTime + favoriteFoods[selectedFooxIndex] = selectedFood + isEditViewActive = false + } + } + + func onFoodDelete(_ food: StoredFavoriteFood) { + if isDetailViewActive { + isDetailViewActive = false + } + withAnimation { + _ = favoriteFoods.remove(food) + } + } + + func onFoodReorder(from: IndexSet, to: Int) { + withAnimation { + favoriteFoods.move(fromOffsets: from, toOffset: to) + } + } + + func addFoodTapped() { + isAddViewActive = true + } + + private func observeFavoriteFoodChange() { + $favoriteFoods + .dropFirst() + .removeDuplicates() + .sink { newValue in + UserDefaults.standard.favoriteFoods = newValue + } + .store(in: &cancellables) + } +} diff --git a/Loop/Views/AddEditFavoriteFoodView.swift b/Loop/Views/AddEditFavoriteFoodView.swift new file mode 100644 index 0000000000..b647523a13 --- /dev/null +++ b/Loop/Views/AddEditFavoriteFoodView.swift @@ -0,0 +1,173 @@ +// +// AddEditFavoriteFoodView.swift +// Loop +// +// Created by Noah Brauner on 7/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct AddEditFavoriteFoodView: View { + @Environment(\.dismiss) var dismiss + + @StateObject private var viewModel: AddEditFavoriteFoodViewModel + + @State private var expandedRow: Row? + @State private var showHowAbsorptionTimeWorks = false + + private var isNewEntry = true + + /// Initializer for adding a new favorite food or editing a `StoredFavoriteFood` + init(originalFavoriteFood: StoredFavoriteFood? = nil, onSave: @escaping (NewFavoriteFood) -> Void) { + self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(originalFavoriteFood: originalFavoriteFood, onSave: onSave)) + self.isNewEntry = originalFavoriteFood == nil + } + + /// Initializer for presenting the `AddEditFavoriteFoodView` prepopulated from the `CarbEntryView` + init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> Void) { + self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(carbsQuantity: carbsQuantity, foodType: foodType, absorptionTime: absorptionTime, onSave: onSave)) + } + + var body: some View { + if isNewEntry { + NavigationView { + content + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + dismissButton + } + + ToolbarItem(placement: .navigationBarTrailing) { + saveButton + } + } + .navigationBarTitle("New Favorite Food", displayMode: .inline) + .onAppear { + expandedRow = .name + } + } + } + else { + content + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if viewModel.updatedFavoriteFood != nil { + dismissButton + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + saveButton + } + } + .navigationBarBackButtonHidden(viewModel.updatedFavoriteFood != nil) + .navigationBarTitle(viewModel.originalFavoriteFood?.title ?? "", displayMode: .inline) + } + } + + private var content: some View { + ZStack { + Color(.systemGroupedBackground) + .edgesIgnoringSafeArea(.all) + + ScrollView { + card + .padding(.top, 8) + + saveActionButton + } + } + .alert(item: $viewModel.alert, content: alert(for:)) + .sheet(isPresented: $showHowAbsorptionTimeWorks) { + HowAbsorptionTimeWorksView() + } + } + + private var card: some View { + VStack(spacing: 10) { + let nameFocused: Binding = Binding(get: { expandedRow == .name }, set: { expandedRow = $0 ? .name : nil }) + let carbQuantityFocused: Binding = Binding(get: { expandedRow == .carbQuantity }, set: { expandedRow = $0 ? .carbQuantity : nil }) + let foodTypeFocused: Binding = Binding(get: { expandedRow == .foodType }, set: { expandedRow = $0 ? .foodType : nil }) + let absorptionTimeFocused: Binding = Binding(get: { expandedRow == .absorptionTime }, set: { expandedRow = $0 ? .absorptionTime : nil }) + + TextFieldRow(text: $viewModel.name, isFocused: nameFocused, title: "Name", placeholder: "Apple") + + CardSectionDivider() + + CarbQuantityRow(quantity: $viewModel.carbsQuantity, isFocused: carbQuantityFocused, title: "Carb Quantity", preferredCarbUnit: viewModel.preferredCarbUnit) + + CardSectionDivider() + + EmojiRow(text: $viewModel.foodType, isFocused: foodTypeFocused, emojiType: .food, title: "Food Type") + + CardSectionDivider() + + AbsorptionTimePickerRow(absorptionTime: $viewModel.absorptionTime, isFocused: absorptionTimeFocused, validDurationRange: viewModel.absorptionRimesRange, showHowAbsorptionTimeWorks: $showHowAbsorptionTimeWorks) + .padding(.bottom, 2) + } + .padding(.vertical, 12) + .padding(.horizontal) + .background(CardBackground()) + .padding(.horizontal) + } + + private func alert(for alert: AddEditFavoriteFoodViewModel.Alert) -> SwiftUI.Alert { + switch alert { + case .maxQuantityExceded: + let message = String( + format: NSLocalizedString("The maximum allowed amount is %@ grams.", comment: "Alert body displayed for quantity greater than max (1: maximum quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.maxCarbEntryQuantity.doubleValue(for: viewModel.preferredCarbUnit)), number: .none) + ) + let okMessage = NSLocalizedString("com.loudnate.LoopKit.errorAlertActionTitle", value: "OK", comment: "The title of the action used to dismiss an error alert") + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + dismissButton: .cancel(Text(okMessage), action: viewModel.clearAlert) + ) + case .warningQuantityValidation: + let message = String( + format: NSLocalizedString("Did you intend to enter %1$@ grams as the amount of carbohydrates for this meal?", comment: "Alert body when entered carbohydrates is greater than threshold (1: entered quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.carbsQuantity ?? 0), number: .none) + ) + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + primaryButton: .default(Text("No, edit amount", comment: "The title of the action used when rejecting the the amount of carbohydrates entered."), action: viewModel.clearAlert), + secondaryButton: .cancel(Text("Yes", comment: "The title of the action used when confirming entered amount of carbohydrates."), action: viewModel.clearAlertAndSave) + ) + } + } +} + +extension AddEditFavoriteFoodView { + private var dismissButton: some View { + Button(action: dismiss.callAsFunction) { + Text("Cancel") + } + } + + private var saveActionButton: some View { + Button(action: viewModel.save) { + Text("Save") + } + .buttonStyle(ActionButtonStyle()) + .padding() + .disabled(viewModel.updatedFavoriteFood == nil) + } + + private var saveButton: some View { + Button(action: viewModel.save) { + Text("Save") + } + .disabled(viewModel.updatedFavoriteFood == nil) + } +} + +extension AddEditFavoriteFoodView { + enum Row { + case name, carbQuantity, foodType, absorptionTime + } +} diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift new file mode 100644 index 0000000000..14c6b2c460 --- /dev/null +++ b/Loop/Views/CarbEntryView.swift @@ -0,0 +1,319 @@ +// +// CarbEntryView.swift +// Loop +// +// Created by Noah Brauner on 7/19/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import HealthKit + +struct CarbEntryView: View, HorizontalSizeClassOverride { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismissAction) private var dismiss + + @ObservedObject var viewModel: CarbEntryViewModel + + @State private var expandedRow: Row? + + @State private var showHowAbsorptionTimeWorks = false + @State private var showAddFavoriteFood = false + + private let isNewEntry: Bool + + init(viewModel: CarbEntryViewModel) { + if viewModel.shouldBeginEditingQuantity { + expandedRow = .amountConsumed + } + isNewEntry = viewModel.originalCarbEntry == nil + self.viewModel = viewModel + } + + var body: some View { + if isNewEntry { + NavigationView { + let title = NSLocalizedString("carb-entry-title-add", value: "Add Carb Entry", comment: "The title of the view controller to create a new carb entry") + content + .navigationBarTitle(title, displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + dismissButton + } + + ToolbarItem(placement: .navigationBarTrailing) { + continueButton + } + } + + } + } + else { + content + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + continueButton + } + } + } + } + + private var content: some View { + ZStack { + Color(.systemGroupedBackground) + .edgesIgnoringSafeArea(.all) + + ScrollView { + warningsCard + + mainCard + .padding(.top, 8) + + continueActionButton + + if isNewEntry, FeatureFlags.allowExperimentalFeatures { + favoriteFoodsCard + } + + let isBolusViewActive = Binding(get: { viewModel.bolusViewModel != nil }, set: { _, _ in viewModel.bolusViewModel = nil }) + NavigationLink(destination: bolusView, isActive: isBolusViewActive) { + EmptyView() + } + .frame(width: 0, height: 0) + .opacity(0) + .accessibility(hidden: true) + } + } + .alert(item: $viewModel.alert, content: alert(for:)) + .sheet(isPresented: $showAddFavoriteFood, onDismiss: clearExpandedRow) { + AddEditFavoriteFoodView(carbsQuantity: $viewModel.carbsQuantity.wrappedValue, foodType: $viewModel.foodType.wrappedValue, absorptionTime: $viewModel.absorptionTime.wrappedValue, onSave: onFavoriteFoodSave(_:)) + } + .sheet(isPresented: $showHowAbsorptionTimeWorks) { + HowAbsorptionTimeWorksView() + } + } + + private var mainCard: some View { + VStack(spacing: 10) { + let amountConsumedFocused: Binding = Binding(get: { expandedRow == .amountConsumed }, set: { expandedRow = $0 ? .amountConsumed : nil }) + let timeFocused: Binding = Binding(get: { expandedRow == .time }, set: { expandedRow = $0 ? .time : nil }) + let foodTypeFocused: Binding = Binding(get: { expandedRow == .foodType }, set: { expandedRow = $0 ? .foodType : nil }) + let absorptionTimeFocused: Binding = Binding(get: { expandedRow == .absorptionTime }, set: { expandedRow = $0 ? .absorptionTime : nil }) + + CarbQuantityRow(quantity: $viewModel.carbsQuantity, isFocused: amountConsumedFocused, title: NSLocalizedString("Amount Consumed", comment: "Label for carb quantity entry row on carb entry screen"), preferredCarbUnit: viewModel.preferredCarbUnit) + + CardSectionDivider() + + DatePickerRow(date: $viewModel.time, isFocused: timeFocused, minimumDate: viewModel.minimumDate, maximumDate: viewModel.maximumDate) + + CardSectionDivider() + + FoodTypeRow(foodType: $viewModel.foodType, absorptionTime: $viewModel.absorptionTime, selectedDefaultAbsorptionTimeEmoji: $viewModel.selectedDefaultAbsorptionTimeEmoji, usesCustomFoodType: $viewModel.usesCustomFoodType, absorptionTimeWasEdited: $viewModel.absorptionTimeWasEdited, isFocused: foodTypeFocused, defaultAbsorptionTimes: viewModel.defaultAbsorptionTimes) + + CardSectionDivider() + + AbsorptionTimePickerRow(absorptionTime: $viewModel.absorptionTime, isFocused: absorptionTimeFocused, validDurationRange: viewModel.absorptionRimesRange, showHowAbsorptionTimeWorks: $showHowAbsorptionTimeWorks) + .padding(.bottom, 2) + } + .padding(.vertical, 12) + .padding(.horizontal) + .background(CardBackground()) + .padding(.horizontal) + } + + @ViewBuilder + private var bolusView: some View { + if let viewModel = viewModel.bolusViewModel { + BolusEntryView(viewModel: viewModel) + .environmentObject(displayGlucosePreference) + .environment(\.dismissAction, dismiss) + } + } + + private func clearExpandedRow() { + self.expandedRow = nil + } +} + +// MARK: - Warnings & Alerts +extension CarbEntryView { + private var warningsCard: some View { + ForEach(Array(viewModel.warnings).sorted(by: { $0.priority < $1.priority })) { warning in + warningView(for: warning) + .padding(.vertical, 8) + .padding(.horizontal) + .background(CardBackground()) + .padding(.horizontal) + .padding(.top, 8) + } + } + + private func warningView(for warning: CarbEntryViewModel.Warning) -> some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(triangleColor(for: warning)) + + Text(warningText(for: warning)) + .font(.caption) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func triangleColor(for warning: CarbEntryViewModel.Warning) -> Color { + switch warning { + case .entryIsMissedMeal: + return .critical + case .overrideInProgress: + return .warning + } + } + + private func warningText(for warning: CarbEntryViewModel.Warning) -> String { + switch warning { + case .entryIsMissedMeal: + return NSLocalizedString("Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an missed meal notification") + case .overrideInProgress: + return NSLocalizedString("An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override.", comment: "Warning to ensure the carb entry is accurate during an override") + } + } + + private func alert(for alert: CarbEntryViewModel.Alert) -> SwiftUI.Alert { + switch alert { + case .maxQuantityExceded: + let message = String( + format: NSLocalizedString("The maximum allowed amount is %@ grams.", comment: "Alert body displayed for quantity greater than max (1: maximum quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.maxCarbEntryQuantity.doubleValue(for: viewModel.preferredCarbUnit)), number: .none) + ) + let okMessage = NSLocalizedString("com.loudnate.LoopKit.errorAlertActionTitle", value: "OK", comment: "The title of the action used to dismiss an error alert") + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + dismissButton: .cancel(Text(okMessage), action: viewModel.clearAlert) + ) + case .warningQuantityValidation: + let message = String( + format: NSLocalizedString("Did you intend to enter %1$@ grams as the amount of carbohydrates for this meal?", comment: "Alert body when entered carbohydrates is greater than threshold (1: entered quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.carbsQuantity ?? 0), number: .none) + ) + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + primaryButton: .default(Text("No, edit amount", comment: "The title of the action used when rejecting the the amount of carbohydrates entered."), action: viewModel.clearAlert), + secondaryButton: .cancel(Text("Yes", comment: "The title of the action used when confirming entered amount of carbohydrates."), action: viewModel.clearAlertAndContinueToBolus) + ) + } + } +} + +// MARK: - Favorite Foods Card +extension CarbEntryView { + private var favoriteFoodsCard: some View { + VStack(alignment: .leading, spacing: 6) { + Text("FAVORITE FOODS") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal, 26) + + VStack(spacing: 10) { + if !viewModel.favoriteFoods.isEmpty { + VStack { + HStack { + Text("Choose Favorite:") + + let selectedFavorite = favoritedFoodTextFromIndex(viewModel.selectedFavoriteFoodIndex) + Text(selectedFavorite) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + if expandedRow == .favoriteFoodSelection { + Picker("", selection: $viewModel.selectedFavoriteFoodIndex) { + ForEach(-1.. String { + if index == -1 { + return "None" + } + else { + let food = viewModel.favoriteFoods[index] + return "\(food.name) \(food.foodType)" + } + } + + private func saveAsFavoriteFood() { + self.showAddFavoriteFood = true + } + + private func onFavoriteFoodSave(_ food: NewFavoriteFood) { + clearExpandedRow() + self.showAddFavoriteFood = false + viewModel.onFavoriteFoodSave(food) + } +} + +// MARK: - Other UI Elements +extension CarbEntryView { + private var dismissButton: some View { + Button(action: dismiss) { + Text("Cancel") + } + } + + private var continueButton: some View { + Button(action: viewModel.continueToBolus) { + Text("Continue") + } + .disabled(viewModel.continueButtonDisabled) + } + + private var continueActionButton: some View { + Button(action: viewModel.continueToBolus) { + Text("Continue") + } + .buttonStyle(ActionButtonStyle()) + .padding() + .disabled(viewModel.continueButtonDisabled) + } + +} + +extension CarbEntryView { + enum Row { + case amountConsumed, time, foodType, absorptionTime, favoriteFoodSelection + } +} diff --git a/Loop/Views/FavoriteFoodDetailView.swift b/Loop/Views/FavoriteFoodDetailView.swift new file mode 100644 index 0000000000..44c7a83150 --- /dev/null +++ b/Loop/Views/FavoriteFoodDetailView.swift @@ -0,0 +1,73 @@ +// +// FavoriteFoodDetailView.swift +// Loop +// +// Created by Noah Brauner on 8/2/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit + +public struct FavoriteFoodDetailView: View { + let food: StoredFavoriteFood? + let onFoodDelete: (StoredFavoriteFood) -> Void + + @State private var isConfirmingDelete = false + + let carbFormatter: QuantityFormatter + let absorptionTimeFormatter: DateComponentsFormatter + let preferredCarbUnit: HKUnit + + public init(food: StoredFavoriteFood?, onFoodDelete: @escaping (StoredFavoriteFood) -> Void, isConfirmingDelete: Bool = false, carbFormatter: QuantityFormatter, absorptionTimeFormatter: DateComponentsFormatter, preferredCarbUnit: HKUnit = HKUnit.gram()) { + self.food = food + self.onFoodDelete = onFoodDelete + self.isConfirmingDelete = isConfirmingDelete + self.carbFormatter = carbFormatter + self.absorptionTimeFormatter = absorptionTimeFormatter + self.preferredCarbUnit = preferredCarbUnit + } + + public var body: some View { + if let food { + List { + Section("Information") { + VStack(spacing: 16) { + let rows: [(field: String, value: String)] = [ + ("Name", food.name), + ("Carb Quantity", food.carbsString(formatter: carbFormatter)), + ("Food Type", food.foodType), + ("Absorption Time", food.absorptionTimeString(formatter: absorptionTimeFormatter)) + ] + ForEach(rows, id: \.field) { row in + HStack { + Text(row.field) + .font(.subheadline) + Spacer() + Text(row.value) + .font(.subheadline) + } + } + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + + Button(role: .destructive, action: { isConfirmingDelete.toggle() }) { + Text("Delete Food") + .frame(maxWidth: .infinity, alignment: .center) // Align text in center + } + } + .alert(isPresented: $isConfirmingDelete) { + Alert( + title: Text("Delete “\(food.name)”?"), + message: Text("Are you sure you want to delete this food?"), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete"), action: { onFoodDelete(food) }) + ) + } + .insetGroupedListStyle() + .navigationTitle(food.title) + } + } +} diff --git a/Loop/Views/FavoriteFoodsView.swift b/Loop/Views/FavoriteFoodsView.swift new file mode 100644 index 0000000000..c2bb941c26 --- /dev/null +++ b/Loop/Views/FavoriteFoodsView.swift @@ -0,0 +1,130 @@ +// +// FavoriteFoodsView.swift +// Loop +// +// Created by Noah Brauner on 7/12/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct FavoriteFoodsView: View { + @Environment(\.dismissAction) private var dismiss + + @StateObject private var viewModel = FavoriteFoodsViewModel() + + @State private var foodToConfirmDeleteId: String? = nil + @State private var editMode: EditMode = .inactive + + var body: some View { + NavigationView { + VStack { + List { + if viewModel.favoriteFoods.isEmpty { + Section { + Text("Selecting a favorite food in the carb entry screen automatically fills in the carb quantity, food type, and absorption time fields! Tap the add button below to create your first favorite food!") + } + } + else { + Section(header: listHeader) { + ForEach(viewModel.favoriteFoods) { food in + FavoriteFoodListRow(food: food, foodToConfirmDeleteId: $foodToConfirmDeleteId, onFoodTap: onFoodTap(_:), onFoodDelete: viewModel.onFoodDelete(_:), carbFormatter: viewModel.carbFormatter, absorptionTimeFormatter: viewModel.absorptionTimeFormatter, preferredCarbUnit: viewModel.preferredCarbUnit) + .environment(\.editMode, self.$editMode) + .listRowInsets(EdgeInsets()) + } + .onMove(perform: viewModel.onFoodReorder(from:to:)) + .moveDisabled(!editMode.isEditing) + .deleteDisabled(true) + } + } + + Section { + addFoodButton + .listRowInsets(EdgeInsets()) + } + } + .insetGroupedListStyle() + + + NavigationLink(destination: AddEditFavoriteFoodView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: $viewModel.isEditViewActive) { + EmptyView() + } + + NavigationLink(destination: FavoriteFoodDetailView(food: viewModel.selectedFood, onFoodDelete: viewModel.onFoodDelete(_:), carbFormatter: viewModel.carbFormatter, absorptionTimeFormatter: viewModel.absorptionTimeFormatter, preferredCarbUnit: viewModel.preferredCarbUnit), isActive: $viewModel.isDetailViewActive) { + EmptyView() + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + dismissButton + } + } + .navigationBarTitle("Favorite Foods", displayMode: .large) + } + .sheet(isPresented: $viewModel.isAddViewActive) { + AddEditFavoriteFoodView(onSave: viewModel.onFoodSave(_:)) + } + .onChange(of: editMode) { newValue in + if !newValue.isEditing { + foodToConfirmDeleteId = nil + } + } + } + + private func onFoodTap(_ food: StoredFavoriteFood) { + viewModel.selectedFood = food + if editMode.isEditing { + viewModel.isEditViewActive = true + } + else { + viewModel.isDetailViewActive = true + } + } +} + +extension FavoriteFoodsView { + private var listHeader: some View { + HStack { + Text("All Favorites") + .font(.title3) + .fontWeight(.semibold) + .textCase(nil) + .foregroundColor(.primary) + + Spacer() + + editButton + } + .listRowInsets(EdgeInsets(top: 20, leading: 4, bottom: 10, trailing: 4)) + } + + private var dismissButton: some View { + Button(action: dismiss) { + Text("Done") + } + } + + private var editButton: some View { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + editMode.toggle() + } + }) { + Text(editMode.title) + .textCase(nil) + } + } + + private var addFoodButton: some View { + Button(action: viewModel.addFoodTapped) { + HStack { + Image(systemName: "plus.circle.fill") + + Text("Add a new favorite food") + } + } + .buttonStyle(ActionButtonStyle()) + } +} diff --git a/Loop/Views/HowAbsorptionTimeWorksView.swift b/Loop/Views/HowAbsorptionTimeWorksView.swift new file mode 100644 index 0000000000..7ac01080e2 --- /dev/null +++ b/Loop/Views/HowAbsorptionTimeWorksView.swift @@ -0,0 +1,33 @@ +// +// HowAbsorptionTimeWorksView.swift +// Loop +// +// Created by Noah Brauner on 7/28/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct HowAbsorptionTimeWorksView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + List { + Section { + Text("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") + } + } + .navigationTitle("Absorption Time") + .toolbar { + dismissButton + } + } + } + + private var dismissButton: some View { + Button(action: dismiss.callAsFunction) { + Text("Close") + } + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index afdc404fcc..0b4cb55133 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -26,6 +26,7 @@ public struct SettingsView: View { @State private var pumpChooserIsPresented: Bool = false @State private var cgmChooserIsPresented: Bool = false + @State private var favoriteFoodsIsPresented: Bool = false @State private var serviceChooserIsPresented: Bool = false @State private var therapySettingsIsPresented: Bool = false @State private var deletePumpDataAlertIsPresented = false @@ -55,6 +56,9 @@ public struct SettingsView: View { configurationSection } deviceSettingsSection + if FeatureFlags.allowExperimentalFeatures { + favoriteFoodsSection + } if (viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice) && viewModel.showDeleteTestData { deleteDataSection } @@ -298,6 +302,19 @@ extension SettingsView { } } + private var favoriteFoodsSection: some View { + Section { + LargeButton(action: { self.favoriteFoodsIsPresented = true }, + includeArrow: true, + imageView: AnyView(Image("Favorite Foods Icon").renderingMode(.template).foregroundColor(carbTintColor)), + label: "Favorite Foods", + descriptiveText: "Simplify Carb Entry") + } + .sheet(isPresented: $favoriteFoodsIsPresented) { + FavoriteFoodsView() + } + } + private var cgmChoices: [ActionSheet.Button] { var result = viewModel.cgmManagerSettingsViewModel.availableDevices .sorted(by: {$0.localizedTitle < $1.localizedTitle}) diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index 63418ad4aa..82ad76b6cc 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -74,7 +74,7 @@ public struct LoopSettings: Equatable { public var automaticDosingStrategy: AutomaticDosingStrategy = .tempBasalOnly public var defaultRapidActingModel: ExponentialInsulinModelPreset? - + public var glucoseUnit: HKUnit? { return glucoseTargetRangeSchedule?.unit } @@ -295,7 +295,7 @@ extension LoopSettings: RawRepresentable { raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue raw["dosingStrategy"] = automaticDosingStrategy.rawValue - + return raw } } diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 40e45612a2..93fa7e17d6 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -19,6 +19,7 @@ extension UserDefaults { case bedtime = "com.loopkit.Loop.bedtime" case lastProfileExpirationAlertDate = "com.loopkit.Loop.lastProfileExpirationAlertDate" case allowDebugFeatures = "com.loopkit.Loop.allowDebugFeatures" + case allowExperimentalFeatures = "com.loopkit.Loop.allowExperimentalFeatures" case allowSimulators = "com.loopkit.Loop.allowSimulators" case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" case userRequestedLoopReset = "com.loopkit.Loop.userRequestedLoopReset" @@ -148,6 +149,10 @@ extension UserDefaults { } } + public var allowExperimentalFeatures: Bool { + return bool(forKey: Key.allowExperimentalFeatures.rawValue) + } + public var allowSimulators: Bool { return bool(forKey: Key.allowSimulators.rawValue) }