diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index aa3559f115..4a4bdb4d05 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -387,6 +387,7 @@ A9F703752489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */; }; A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */; }; A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */; }; + 2437A340C1560EEDE55E3F7B /* CarbEntryChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8378B7D1F532A5F47CEBB6AE /* CarbEntryChange.swift */; }; B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CED28CBBC82002FB414 /* AlertManagementView.swift */; }; A1E47000000000000000A001 /* AlertHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E47000000000000000A002 /* AlertHistoryView.swift */; }; B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; @@ -1199,6 +1200,7 @@ A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStore+SimulatedCoreData.swift"; sourceTree = ""; }; A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentDeviceLog+SimulatedCoreData.swift"; sourceTree = ""; }; A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDosingDecision.swift; sourceTree = ""; }; + 8378B7D1F532A5F47CEBB6AE /* CarbEntryChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryChange.swift; sourceTree = ""; }; AF16A5EDEB9DF756890E0C09 /* Loop/Resources/Sounds/bloop.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/bloop.caf; sourceTree = SOURCE_ROOT; }; B4001CED28CBBC82002FB414 /* AlertManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagementView.swift; sourceTree = ""; }; A1E47000000000000000A002 /* AlertHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertHistoryView.swift; sourceTree = ""; }; @@ -1708,6 +1710,7 @@ DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, FEEDF0000000000000000000 /* GlucoseStatistics.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, + 8378B7D1F532A5F47CEBB6AE /* CarbEntryChange.swift */, DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, 1455ACB12C667BEE004F44F2 /* Deeplink.swift */, @@ -3636,6 +3639,7 @@ 1455ACB22C667BEE004F44F2 /* Deeplink.swift in Sources */, 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */, + 2437A340C1560EEDE55E3F7B /* CarbEntryChange.swift in Sources */, 892A5D59222F0A27008961AB /* Debug.swift in Sources */, 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, diff --git a/Loop/Extensions/CarbStore+SimulatedCoreData.swift b/Loop/Extensions/CarbStore+SimulatedCoreData.swift index f29f645b05..71cca3c2fc 100644 --- a/Loop/Extensions/CarbStore+SimulatedCoreData.swift +++ b/Loop/Extensions/CarbStore+SimulatedCoreData.swift @@ -61,6 +61,7 @@ extension CarbStore { fileprivate extension NewCarbEntry { static func simulated(startDate: Date, grams: Double, absorptionTime: TimeInterval) -> NewCarbEntry { return NewCarbEntry(date: startDate, + syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: grams), startDate: startDate, foodType: "Simulated", diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index e1de76dd77..3091e30489 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -976,7 +976,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let missedEntry = NewCarbEntry(quantity: LoopQuantity(unit: .gram, + let missedEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: carbAmount), startDate: mealTime, foodType: nil, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index df086d0bcd..2ef40ed850 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -808,14 +808,13 @@ final class LoopDataManager: ObservableObject { func recommendManualBolus( manualGlucoseSample: NewGlucoseSample? = nil, - potentialCarbEntry: NewCarbEntry? = nil, - originalCarbEntry: StoredCarbEntry? = nil, + carbEntryChange: CarbEntryChange? = nil, truncatingActiveOverride: Bool = false ) async throws -> ManualBolusRecommendation? { var endingPremealOverride = false - if potentialCarbEntry != nil, + if carbEntryChange != nil, let activeOverride = temporaryPresetsManager.activeOverride, activeOverride.context == .preMeal { @@ -824,8 +823,8 @@ final class LoopDataManager: ObservableObject { var input = try await self.fetchData(for: now, presumePresetEndingNow: truncatingActiveOverride || endingPremealOverride) .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) - .removingCarbEntry(carbEntry: originalCarbEntry) - .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) + .removingCarbEntry(carbEntry: carbEntryChange?.originalEntry) + .addingCarbEntry(carbEntry: carbEntryChange?.asStoredCarbEntry) input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses input.recommendationType = .manualBolus @@ -990,16 +989,15 @@ extension LoopDataManager { /// Adds and stores carb data, and recommends a bolus if needed /// /// - Parameters: - /// - carbEntry: The new carb value - /// - completion: A closure called once upon completion - /// - result: The bolus recommendation - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? = nil) async throws -> StoredCarbEntry { - let storedCarbEntry: StoredCarbEntry - if let replacingEntry = replacingEntry { - storedCarbEntry = try await carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry) - } else { - storedCarbEntry = try await carbStore.addCarbEntry(carbEntry) - } + /// - carbEntry: The new carb value, stored once however often it is retried + func addCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + let storedCarbEntry = try await carbStore.addCarbEntry(carbEntry) + self.temporaryPresetsManager.endPreMealOverride() + return storedCarbEntry + } + + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, with update: CarbEntryUpdate) async throws -> StoredCarbEntry { + let storedCarbEntry = try await carbStore.replaceCarbEntry(oldEntry, withEntry: update) self.temporaryPresetsManager.endPreMealOverride() return storedCarbEntry } @@ -1203,6 +1201,18 @@ extension NewCarbEntry { } } +extension CarbEntryUpdate { + var asStoredCarbEntry: StoredCarbEntry { + StoredCarbEntry( + startDate: startDate, + quantity: quantity, + foodType: foodType, + absorptionTime: absorptionTime, + userCreatedDate: date + ) + } +} + extension NewGlucoseSample { var asStoredGlucoseSample: StoredGlucoseSample { StoredGlucoseSample( @@ -1386,7 +1396,7 @@ extension LoopDataManager: ServicesManagerDelegate { } let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) - let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) + let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) let _ = try await carbStore.addCarbEntry(candidateCarbEntry) } @@ -1473,15 +1483,14 @@ extension LoopDataManager: BolusEntryViewModelDelegate { } func generatePrediction( - originalCarbEntry: StoredCarbEntry?, - potentialCarbEntry: NewCarbEntry?, + carbEntryChange: CarbEntryChange?, potentialDose: SimpleInsulinDose?, manualGlucose: NewGlucoseSample? ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) { var endingPremealOverride = false - if potentialCarbEntry != nil, + if carbEntryChange != nil, let activeOverride = temporaryPresetsManager.activeOverride, activeOverride.context == .preMeal { @@ -1494,8 +1503,8 @@ extension LoopDataManager: BolusEntryViewModelDelegate { input = input .addingDose(dose: potentialDose) .addingGlucoseSample(sample: manualGlucose?.asStoredGlucoseSample) - .removingCarbEntry(carbEntry: originalCarbEntry) - .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) + .removingCarbEntry(carbEntry: carbEntryChange?.originalEntry) + .addingCarbEntry(carbEntry: carbEntryChange?.asStoredCarbEntry) let prediction = try input.predictGlucose() diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index b6c7d16c5e..7b0006b57f 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -12,7 +12,7 @@ protocol CarbStoreProtocol: AnyObject { func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, fetchLimit: Int?, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: CarbEntryUpdate) async throws -> StoredCarbEntry func addCarbEntry(_ entry: NewCarbEntry) async throws -> StoredCarbEntry diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 92a9557101..138fa5126a 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -307,8 +307,7 @@ final class WatchDataManager: NSObject { if let recommendedBolus = try? await loopDataManager.recommendManualBolus( manualGlucoseSample: nil, - potentialCarbEntry: potentialCarbEntry, - originalCarbEntry: nil + carbEntryChange: potentialCarbEntry.map { .add($0) } ) { context.recommendedBolusDose = recommendedBolus.amount dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( diff --git a/Loop/Models/CarbEntryChange.swift b/Loop/Models/CarbEntryChange.swift new file mode 100644 index 0000000000..a858fa4a5b --- /dev/null +++ b/Loop/Models/CarbEntryChange.swift @@ -0,0 +1,58 @@ +// +// CarbEntryChange.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm +import LoopKit + +/// A carb entry about to be saved: a new entry, or new content for a stored one. +enum CarbEntryChange: Equatable { + case add(NewCarbEntry) + case edit(StoredCarbEntry, CarbEntryUpdate) + + var originalEntry: StoredCarbEntry? { + if case .edit(let original, _) = self { + return original + } + return nil + } + + var quantity: LoopQuantity { + switch self { + case .add(let entry): entry.quantity + case .edit(_, let update): update.quantity + } + } + + var startDate: Date { + switch self { + case .add(let entry): entry.startDate + case .edit(_, let update): update.startDate + } + } + + var foodType: String? { + switch self { + case .add(let entry): entry.foodType + case .edit(_, let update): update.foodType + } + } + + var absorptionTime: TimeInterval? { + switch self { + case .add(let entry): entry.absorptionTime + case .edit(_, let update): update.absorptionTime + } + } + + var asStoredCarbEntry: StoredCarbEntry { + switch self { + case .add(let entry): entry.asStoredCarbEntry + case .edit(_, let update): update.asStoredCarbEntry + } + } +} diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index b3b24dbc67..18fa58e64e 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -30,7 +30,8 @@ protocol BolusEntryViewModelDelegate: AnyObject { func fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?, projectOngoingDoses: Bool) async throws -> StoredDataAlgorithmInput func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry + func addCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, with update: CarbEntryUpdate) async throws -> StoredCarbEntry func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws @@ -39,14 +40,12 @@ protocol BolusEntryViewModelDelegate: AnyObject { func recommendManualBolus( manualGlucoseSample: NewGlucoseSample?, - potentialCarbEntry: NewCarbEntry?, - originalCarbEntry: StoredCarbEntry?, + carbEntryChange: CarbEntryChange?, truncatingActiveOverride: Bool ) async throws -> ManualBolusRecommendation? func generatePrediction( - originalCarbEntry: StoredCarbEntry?, - potentialCarbEntry: NewCarbEntry?, + carbEntryChange: CarbEntryChange?, potentialDose: SimpleInsulinDose?, manualGlucose: NewGlucoseSample? ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) @@ -107,8 +106,7 @@ final class BolusEntryViewModel: ObservableObject { @Published var scheduleOverride: TemporaryScheduleOverride? var maximumBolus: LoopQuantity? - let originalCarbEntry: StoredCarbEntry? - let potentialCarbEntry: NewCarbEntry? + let carbEntryChange: CarbEntryChange? let selectedCarbAbsorptionTimeEmoji: String? @Published var recommendedBolus: LoopQuantity? @@ -167,8 +165,7 @@ final class BolusEntryViewModel: ObservableObject { debounceIntervalMilliseconds: Int = 400, uuidProvider: @escaping () -> String = { UUID().uuidString }, timeZone: TimeZone? = nil, - originalCarbEntry: StoredCarbEntry? = nil, - potentialCarbEntry: NewCarbEntry? = nil, + carbEntryChange: CarbEntryChange? = nil, selectedCarbAbsorptionTimeEmoji: String? = nil, isManualGlucoseEntryEnabled: Bool = false ) { @@ -184,15 +181,14 @@ final class BolusEntryViewModel: ObservableObject { self.carbEntryDateFormatter.timeZone = timeZone } - self.originalCarbEntry = originalCarbEntry - self.potentialCarbEntry = potentialCarbEntry + self.carbEntryChange = carbEntryChange self.selectedCarbAbsorptionTimeEmoji = selectedCarbAbsorptionTimeEmoji self.isManualGlucoseEntryEnabled = isManualGlucoseEntryEnabled self.chartDateInterval = DateInterval(start: Date(timeInterval: .hours(-1), since: now()), duration: .hours(7)) - self.dosingDecision.originalCarbEntry = originalCarbEntry + self.dosingDecision.originalCarbEntry = carbEntryChange?.originalEntry self.updateSettings() } @@ -298,8 +294,13 @@ final class BolusEntryViewModel: ObservableObject { return recommendedBolusAmount > 0 } - func saveCarbEntry(_ entry: NewCarbEntry, replacingEntry: StoredCarbEntry?) async -> StoredCarbEntry? { - try? await delegate?.addCarbEntry(entry, replacing: replacingEntry) + func saveCarbEntry(_ change: CarbEntryChange) async -> StoredCarbEntry? { + switch change { + case .add(let entry): + try? await delegate?.addCarbEntry(entry) + case .edit(let original, let update): + try? await delegate?.replaceCarbEntry(original, with: update) + } } // returns true if action succeeded @@ -345,7 +346,6 @@ final class BolusEntryViewModel: ObservableObject { let amountToDeliverString = formatBolusAmount(amountToDeliver) let manualGlucoseSample = manualGlucoseSample - let potentialCarbEntry = potentialCarbEntry if let manualGlucoseSample = manualGlucoseSample { guard LoopConstants.validManualGlucoseEntryRange.contains(manualGlucoseSample.quantity) else { @@ -376,8 +376,8 @@ final class BolusEntryViewModel: ObservableObject { let activationType = BolusActivationType.activationTypeFor(recommendedAmount: recommendedBolus?.doubleValue(for: .internationalUnit), bolusAmount: amountToDeliver) - if let carbEntry = potentialCarbEntry { - if originalCarbEntry == nil { + if let carbEntryChange { + if case .add = carbEntryChange { let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) do { @@ -386,7 +386,7 @@ final class BolusEntryViewModel: ObservableObject { log.error("Failed to donate intent: %{public}@", String(describing: error)) } } - if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) { + if let storedCarbEntry = await saveCarbEntry(carbEntryChange) { self.dosingDecision.carbEntry = storedCarbEntry self.analyticsServicesManager?.didAddCarbs(source: "Phone", isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) } else { @@ -450,13 +450,13 @@ final class BolusEntryViewModel: ObservableObject { var carbEntryAmountAndEmojiString: String? { guard - let potentialCarbEntry = potentialCarbEntry, - let carbAmountString = QuantityFormatter(for: .gram).string(from: potentialCarbEntry.quantity) + let carbEntryChange, + let carbAmountString = QuantityFormatter(for: .gram).string(from: carbEntryChange.quantity) else { return nil } - if let emoji = potentialCarbEntry.foodType ?? selectedCarbAbsorptionTimeEmoji { + if let emoji = carbEntryChange.foodType ?? selectedCarbAbsorptionTimeEmoji { return String(format: NSLocalizedString("%1$@ %2$@", comment: "Format string combining carb entry quantity and absorption time emoji"), carbAmountString, emoji) } else { return carbAmountString @@ -464,13 +464,13 @@ final class BolusEntryViewModel: ObservableObject { } var carbEntryDateAndAbsorptionTimeString: String? { - guard let potentialCarbEntry = potentialCarbEntry else { + guard let carbEntryChange else { return nil } - let entryTimeString = carbEntryDateFormatter.string(from: potentialCarbEntry.startDate) + let entryTimeString = carbEntryDateFormatter.string(from: carbEntryChange.startDate) - if let absorptionTime = potentialCarbEntry.absorptionTime, let absorptionTimeString = absorptionTimeFormatter.string(from: absorptionTime) { + if let absorptionTime = carbEntryChange.absorptionTime, let absorptionTimeString = absorptionTimeFormatter.string(from: absorptionTime) { return String(format: NSLocalizedString("%1$@ + %2$@", comment: "Format string combining carb entry time and absorption time"), entryTimeString, absorptionTimeString) } else { return entryTimeString @@ -534,8 +534,7 @@ final class BolusEntryViewModel: ObservableObject { ) let (glucoseHistory, prediction) = try await delegate.generatePrediction( - originalCarbEntry: originalCarbEntry, - potentialCarbEntry: potentialCarbEntry, + carbEntryChange: carbEntryChange, potentialDose: enteredBolusDose, manualGlucose: manualGlucoseSample ) @@ -651,8 +650,7 @@ final class BolusEntryViewModel: ObservableObject { return try await delegate.recommendManualBolus( manualGlucoseSample: manualGlucoseSample, - potentialCarbEntry: potentialCarbEntry, - originalCarbEntry: originalCarbEntry, + carbEntryChange: carbEntryChange, truncatingActiveOverride: truncatingActiveOverride ) } @@ -664,7 +662,7 @@ final class BolusEntryViewModel: ObservableObject { targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule // Pre-meal override should be ignored if we have carbs (LOOP-1964) - preMealOverride = potentialCarbEntry == nil ? delegate.preMealOverride : nil + preMealOverride = carbEntryChange == nil ? delegate.preMealOverride : nil scheduleOverride = delegate.scheduleOverride if preMealOverride?.hasFinished() == true { @@ -682,7 +680,7 @@ final class BolusEntryViewModel: ObservableObject { dosingDecision.scheduleOverride = scheduleOverride if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = delegate.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + dosingDecision.glucoseTargetRangeSchedule = delegate.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: carbEntryChange != nil) } else { dosingDecision.glucoseTargetRangeSchedule = targetGlucoseSchedule } @@ -766,7 +764,7 @@ extension BolusEntryViewModel { } private var hasDataToSave: Bool { - manualGlucoseQuantity != nil || potentialCarbEntry != nil + manualGlucoseQuantity != nil || carbEntryChange != nil } enum ButtonChoice { case manualGlucoseEntry, actionButton } diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index e124589ce7..719a2daa22 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -147,23 +147,39 @@ final class CarbEntryViewModel: ObservableObject { } var originalCarbEntry: StoredCarbEntry? = nil - - private var updatedCarbEntry: NewCarbEntry? { + private let newEntrySyncIdentifier = UUID().uuidString + + private var carbEntryChange: CarbEntryChange? { if let quantity = carbsQuantity, quantity != 0 { let favoriteFoodID = selectedFavoriteFoodIndex == -1 ? nil : favoriteFoods[selectedFavoriteFoodIndex].id if let o = originalCarbEntry, o.quantity.doubleValue(for: preferredCarbUnit) == quantity && o.startDate == time && o.foodType == foodType && o.absorptionTime == absorptionTime, o.favoriteFoodID == favoriteFoodID { return nil // No changes were made } - - return NewCarbEntry( + + let carbsQuantity = LoopQuantity(unit: preferredCarbUnit, doubleValue: quantity) + let entryFoodType = usesCustomFoodType ? foodType : selectedDefaultAbsorptionTimeEmoji + + guard let originalCarbEntry else { + return .add(NewCarbEntry( + date: date, + syncIdentifier: newEntrySyncIdentifier, + quantity: carbsQuantity, + startDate: time, + foodType: entryFoodType, + absorptionTime: absorptionTime, + favoriteFoodID: favoriteFoodID + )) + } + + return .edit(originalCarbEntry, CarbEntryUpdate( date: date, - quantity: LoopQuantity(unit: preferredCarbUnit, doubleValue: quantity), + quantity: carbsQuantity, startDate: time, - foodType: usesCustomFoodType ? foodType : selectedDefaultAbsorptionTimeEmoji, + foodType: entryFoodType, absorptionTime: absorptionTime, favoriteFoodID: favoriteFoodID - ) + )) } else { return nil @@ -180,12 +196,12 @@ final class CarbEntryViewModel: ObservableObject { } var continueButtonDisabled: Bool { - get { updatedCarbEntry == nil } + get { carbEntryChange == nil } } // MARK: - Continue to Bolus and Carb Quantity Warnings func continueToBolus() { - guard updatedCarbEntry != nil else { + guard carbEntryChange != nil else { return } @@ -217,8 +233,7 @@ final class CarbEntryViewModel: ObservableObject { let viewModel = BolusEntryViewModel( delegate: delegate, screenWidth: UIScreen.main.bounds.width, - originalCarbEntry: originalCarbEntry, - potentialCarbEntry: updatedCarbEntry, + carbEntryChange: carbEntryChange, selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji ) diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index 7d48563871..4053621700 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -21,7 +21,7 @@ protocol SimpleBolusViewModelDelegate: AnyObject { func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry + func addCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async @@ -391,10 +391,10 @@ class SimpleBolusViewModel: ObservableObject { log.error("Failed to donate intent: %{public}@", String(describing: error)) } - let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) + let carbEntry = NewCarbEntry(date: saveDate, syncIdentifier: UUID().uuidString, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) do { - self.dosingDecision?.carbEntry = try await delegate.addCarbEntry(carbEntry, replacing: nil) + self.dosingDecision?.carbEntry = try await delegate.addCarbEntry(carbEntry) } catch { self.presentAlert(.carbEntryPersistenceFailure) self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 91042043a3..ef658d724b 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -76,7 +76,7 @@ struct BolusEntryView: View { } private var title: Text { - if viewModel.potentialCarbEntry == nil { + if viewModel.carbEntryChange == nil { return Text("Bolus", comment: "Title for bolus entry screen") } return Text("Meal Bolus", comment: "Title for bolus entry screen when also entering carbs") @@ -208,7 +208,7 @@ struct BolusEntryView: View { if viewModel.isManualGlucoseEntryEnabled { ManualGlucoseEntryRow(quantity: $viewModel.manualGlucoseQuantity) - } else if viewModel.potentialCarbEntry != nil { + } else if viewModel.carbEntryChange != nil { potentialCarbEntryRow } else { recommendedBolusRow @@ -216,11 +216,11 @@ struct BolusEntryView: View { } .padding(.top, 8) - if viewModel.isManualGlucoseEntryEnabled && viewModel.potentialCarbEntry != nil { + if viewModel.isManualGlucoseEntryEnabled && viewModel.carbEntryChange != nil { potentialCarbEntryRow } - if viewModel.isManualGlucoseEntryEnabled || viewModel.potentialCarbEntry != nil { + if viewModel.isManualGlucoseEntryEnabled || viewModel.carbEntryChange != nil { recommendedBolusRow } diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index b7efbfd119..a26032029f 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -371,13 +371,13 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { return StoredGlucoseSample(startDate: sample.date, quantity: sample.quantity) } - func addCarbEntry(_ carbEntry: LoopKit.NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { + func addCarbEntry(_ carbEntry: LoopKit.NewCarbEntry) async throws -> StoredCarbEntry { StoredCarbEntry( startDate: carbEntry.startDate, quantity: carbEntry.quantity, uuid: UUID(), provenanceIdentifier: UUID().uuidString, - syncIdentifier: UUID().uuidString, + syncIdentifier: carbEntry.syncIdentifier, syncVersion: 1, foodType: carbEntry.foodType, absorptionTime: carbEntry.absorptionTime, diff --git a/LoopCore/GetBolusRecommendationUserInfo.swift b/LoopCore/GetBolusRecommendationUserInfo.swift index 63f45547ed..a5bc9fd446 100644 --- a/LoopCore/GetBolusRecommendationUserInfo.swift +++ b/LoopCore/GetBolusRecommendationUserInfo.swift @@ -31,9 +31,10 @@ extension GetBolusRecommendationUserInfo: RawRepresentable { return nil } - if let value = rawValue["ce"] as? NewCarbEntry.RawValue, - let carbEntry = NewCarbEntry(rawValue: value) - { + if let rawCarbEntry = rawValue["ce"] { + guard let carbEntry = (rawCarbEntry as? NewCarbEntry.RawValue).flatMap(NewCarbEntry.init(rawValue:)) else { + return nil + } self.carbEntry = carbEntry } else { self.carbEntry = nil diff --git a/LoopCore/Models/SetBolusUserInfo.swift b/LoopCore/Models/SetBolusUserInfo.swift index 137e5659b8..4c686d032a 100644 --- a/LoopCore/Models/SetBolusUserInfo.swift +++ b/LoopCore/Models/SetBolusUserInfo.swift @@ -47,7 +47,14 @@ extension SetBolusUserInfo: RawRepresentable { self.value = value self.startDate = startDate self.contextDate = rawValue["cd"] as? Date - self.carbEntry = (rawValue["ce"] as? NewCarbEntry.RawValue).flatMap(NewCarbEntry.init(rawValue:)) + if let rawCarbEntry = rawValue["ce"] { + guard let carbEntry = (rawCarbEntry as? NewCarbEntry.RawValue).flatMap(NewCarbEntry.init(rawValue:)) else { + return nil + } + self.carbEntry = carbEntry + } else { + self.carbEntry = nil + } self.activationType = activationType } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 36f749c870..66e0ea6664 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -111,33 +111,33 @@ extension MissedMealTestType { switch self { case .missedMealWithCOB: return [ - NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), + NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 10), + NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: 10), startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, foodType: nil, absorptionTime: nil) ] case .noMealWithCOB: return [ - NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), + NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-17T22:40:00")!, foodType: nil, absorptionTime: nil) ] case .manyMeals: return [ - NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), + NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 10), + NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: 10), startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 40), + NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: 40), startDate: Self.dateFormatter.date(from: "2022-10-19T19:11:43")!, foodType: nil, absorptionTime: nil) diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index cce5401699..05e3dd81b1 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -19,7 +19,7 @@ class MockCarbStore: CarbStoreProtocol { return carbHistory.filterDateRange(start, end) } - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry { + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: CarbEntryUpdate) async throws -> StoredCarbEntry { let stored = newEntry.asStoredCarbEntry carbHistory = carbHistory.map({ entry in if entry.syncIdentifier == oldEntry.syncIdentifier { diff --git a/LoopTests/Models/SetBolusUserInfoTests.swift b/LoopTests/Models/SetBolusUserInfoTests.swift index 05c9d51838..7ce903faa8 100644 --- a/LoopTests/Models/SetBolusUserInfoTests.swift +++ b/LoopTests/Models/SetBolusUserInfoTests.swift @@ -17,6 +17,7 @@ class SetBolusUserInfoTests: XCTestCase { private var startDate = dateFormatter.date(from: "2020-05-14T22:45:00Z")! private var contextDate = dateFormatter.date(from: "2020-05-14T22:38:14Z")! private var carbEntry = NewCarbEntry(date: dateFormatter.date(from: "2020-05-14T22:39:34Z")!, + syncIdentifier: "2A67A303-1234-4CB8-1234-3D5A2B5A9F9A", quantity: LoopQuantity(unit: .gram, doubleValue: 17), startDate: dateFormatter.date(from: "2020-05-14T22:00:00Z")!, foodType: "Pizza", @@ -52,6 +53,22 @@ class SetBolusUserInfoTests: XCTestCase { XCTAssertEqual(info?.carbEntry, carbEntry) } + func testRawValueInitializerWithoutCarbEntry() { + var rawValue = self.rawValue + rawValue["ce"] = nil + let info = SetBolusUserInfo(rawValue: rawValue) + XCTAssertEqual(info?.value, value) + XCTAssertNil(info?.carbEntry) + } + + func testRawValueInitializerCarbEntryMissingSyncIdentifier() { + var rawValue = self.rawValue + var rawCarbEntry = carbEntry.rawValue + rawCarbEntry["syncIdentifier"] = nil + rawValue["ce"] = rawCarbEntry + XCTAssertNil(SetBolusUserInfo(rawValue: rawValue)) + } + func testRawValueInitializerMissingVersion() { var rawValue = self.rawValue rawValue["v"] = nil diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 5668c9e455..dd26c27822 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -72,7 +72,9 @@ class BolusEntryViewModelTests: XCTestCase { createdByCurrentApp: true, userCreatedDate: BolusEntryViewModelTests.now, userUpdatedDate: BolusEntryViewModelTests.now) - let mockPotentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: BolusEntryViewModelTests.exampleStartDate, foodType: "foodType", absorptionTime: 1) + let mockPotentialCarbEntry = NewCarbEntry(syncIdentifier: "newSyncIdentifier", quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: BolusEntryViewModelTests.exampleStartDate, foodType: "foodType", absorptionTime: 1) + let mockCarbEntryUpdate = CarbEntryUpdate(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: BolusEntryViewModelTests.exampleStartDate, foodType: "foodType", absorptionTime: 1) + var mockCarbEntryEdit: CarbEntryChange { .edit(mockOriginalCarbEntry, mockCarbEntryUpdate) } let mockFinalCarbEntry = StoredCarbEntry( startDate: BolusEntryViewModelTests.exampleStartDate, quantity: BolusEntryViewModelTests.exampleCarbQuantity, @@ -103,15 +105,14 @@ class BolusEntryViewModelTests: XCTestCase { } } - func setUpViewModel(originalCarbEntry: StoredCarbEntry? = nil, potentialCarbEntry: NewCarbEntry? = nil, selectedCarbAbsorptionTimeEmoji: String? = nil) async { + func setUpViewModel(carbEntryChange: CarbEntryChange? = nil, selectedCarbAbsorptionTimeEmoji: String? = nil) async { bolusEntryViewModel = BolusEntryViewModel(delegate: delegate, now: { self.now }, screenWidth: 512, debounceIntervalMilliseconds: 0, uuidProvider: { self.mockUUID }, timeZone: TimeZone(abbreviation: "GMT")!, - originalCarbEntry: originalCarbEntry, - potentialCarbEntry: potentialCarbEntry, + carbEntryChange: carbEntryChange, selectedCarbAbsorptionTimeEmoji: selectedCarbAbsorptionTimeEmoji) bolusEntryViewModel.authenticationHandler = { _ in return true } @@ -237,7 +238,7 @@ class BolusEntryViewModelTests: XCTestCase { } func testUpdateSettingsWithCarbs() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: mockCarbEntryEdit) XCTAssertNil(bolusEntryViewModel.preMealOverride) XCTAssertNil(bolusEntryViewModel.scheduleOverride) XCTAssertEqual(bolusEntryViewModel.targetGlucoseSchedule, BolusEntryViewModelTests.exampleGlucoseRangeSchedule) @@ -293,11 +294,11 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusNoNotice() async throws { let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) - let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = CarbEntryUpdate.mock(40, at: now.addingTimeInterval(-.minutes(5))) delegate.loopStateInput.carbEntries = [originalCarbEntry] - await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + await setUpViewModel(carbEntryChange: .edit(originalCarbEntry, editedCarbEntry)) XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendation = ManualBolusRecommendation(amount: 1.25) @@ -310,8 +311,8 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) - XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation?.quantity, originalCarbEntry.quantity) - XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation?.quantity, editedCarbEntry.quantity) + XCTAssertEqual(delegate.carbEntryChangeForBolusRecommendation?.originalEntry?.quantity, originalCarbEntry.quantity) + XCTAssertEqual(delegate.carbEntryChangeForBolusRecommendation?.quantity, editedCarbEntry.quantity) XCTAssertNil(delegate.manualGlucoseSampleForBolusRecommendation) XCTAssertNil(bolusEntryViewModel.activeNotice) @@ -410,11 +411,11 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusWithManual() async throws { let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) - let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = CarbEntryUpdate.mock(40, at: now.addingTimeInterval(-.minutes(5))) delegate.loopStateInput.carbEntries = [originalCarbEntry] - await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + await setUpViewModel(carbEntryChange: .edit(originalCarbEntry, editedCarbEntry)) let manualGlucoseQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123) @@ -430,8 +431,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) - XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation, editedCarbEntry) - XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation, originalCarbEntry) + XCTAssertEqual(delegate.carbEntryChangeForBolusRecommendation, .edit(originalCarbEntry, editedCarbEntry)) XCTAssertEqual(delegate.manualGlucoseSampleForBolusRecommendation?.quantity, manualGlucoseQuantity) XCTAssertNil(bolusEntryViewModel.activeNotice) @@ -549,20 +549,21 @@ class BolusEntryViewModelTests: XCTestCase { } func testSaveCarbGlucoseNoBolus() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: mockCarbEntryEdit) delegate.addCarbEntryResult = .success(mockFinalCarbEntry) try await saveAndDeliver(BolusEntryViewModelTests.noBolus) XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) - XCTAssertEqual(1, delegate.carbEntriesAdded.count) + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.carbEntriesReplaced.count) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.originalCarbEntry, mockOriginalCarbEntry) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.carbEntry, mockFinalCarbEntry) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 0.0) - XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesAdded.first?.1) + XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesReplaced.first?.0) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) XCTAssertNil(delegate.enactedBolusUnits) @@ -594,16 +595,17 @@ class BolusEntryViewModelTests: XCTestCase { } func testSaveCarbAndBolus() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: mockCarbEntryEdit) delegate.addCarbEntryResult = .success(mockFinalCarbEntry) try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) - XCTAssertEqual(1, delegate.carbEntriesAdded.count) - XCTAssertEqual(mockPotentialCarbEntry, delegate.carbEntriesAdded.first?.0) - XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesAdded.first?.1) + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.carbEntriesReplaced.count) + XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesReplaced.first?.0) + XCTAssertEqual(mockCarbEntryUpdate, delegate.carbEntriesReplaced.first?.1) XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.originalCarbEntry, mockOriginalCarbEntry) @@ -615,8 +617,23 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(saveAndDeliverSuccess) } + func testSaveNewCarbEntryAndBolus() async throws { + await setUpViewModel(carbEntryChange: .add(mockPotentialCarbEntry)) + + delegate.addCarbEntryResult = .success(mockFinalCarbEntry) + + try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) + + XCTAssertEqual([mockPotentialCarbEntry], delegate.carbEntriesAdded) + XCTAssertTrue(delegate.carbEntriesReplaced.isEmpty) + XCTAssertNil(delegate.bolusDosingDecisionsAdded.first?.0.originalCarbEntry) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.carbEntry, mockFinalCarbEntry) + XCTAssertEqual(1.0, delegate.enactedBolusUnits) + XCTAssertTrue(saveAndDeliverSuccess) + } + func testSaveCarbAndBolusClearsSavedPreMealOverride() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: mockCarbEntryEdit) // set up user specified pre-meal override let newGlucoseTargetRangeSchedule = GlucoseRangeSchedule(unit: .millimolesPerLiter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), @@ -645,7 +662,7 @@ class BolusEntryViewModelTests: XCTestCase { } func testSaveManualGlucoseAndCarbAndBolus() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: mockCarbEntryEdit) bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity delegate.addCarbEntryResult = .success(mockFinalCarbEntry) @@ -655,9 +672,10 @@ class BolusEntryViewModelTests: XCTestCase { let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) XCTAssertEqual([expectedGlucoseSample], delegate.glucoseSamplesAdded) - XCTAssertEqual(1, delegate.carbEntriesAdded.count) - XCTAssertEqual(mockPotentialCarbEntry, delegate.carbEntriesAdded.first?.0) - XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesAdded.first?.1) + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.carbEntriesReplaced.count) + XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesReplaced.first?.0) + XCTAssertEqual(mockCarbEntryUpdate, delegate.carbEntriesReplaced.first?.1) XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 1.0) @@ -690,21 +708,21 @@ class BolusEntryViewModelTests: XCTestCase { } func testCarbEntryAmountAndEmojiString() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: mockCarbEntryEdit) XCTAssertEqual("234 g foodType", bolusEntryViewModel.carbEntryAmountAndEmojiString) } func testCarbEntryAmountAndEmojiStringNoFoodType() async throws { - let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: 1) - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry) + let potentialCarbEntry = CarbEntryUpdate(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: 1) + await setUpViewModel(carbEntryChange: .edit(mockOriginalCarbEntry, potentialCarbEntry)) XCTAssertEqual("234 g", bolusEntryViewModel.carbEntryAmountAndEmojiString) } func testCarbEntryAmountAndEmojiStringWithEmoji() async throws { - let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: 1) - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry, selectedCarbAbsorptionTimeEmoji: "😀") + let potentialCarbEntry = CarbEntryUpdate(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: 1) + await setUpViewModel(carbEntryChange: .edit(mockOriginalCarbEntry, potentialCarbEntry), selectedCarbAbsorptionTimeEmoji: "😀") XCTAssertEqual("234 g 😀", bolusEntryViewModel.carbEntryAmountAndEmojiString) } @@ -714,14 +732,14 @@ class BolusEntryViewModelTests: XCTestCase { } func testCarbEntryDateAndAbsorptionTimeString() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: mockCarbEntryEdit) XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) } func testCarbEntryDateAndAbsorptionTimeString2() async throws { - let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: nil) - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry) + let potentialCarbEntry = CarbEntryUpdate(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: nil) + await setUpViewModel(carbEntryChange: .edit(mockOriginalCarbEntry, potentialCarbEntry)) XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) } @@ -779,12 +797,12 @@ class BolusEntryViewModelTests: XCTestCase { } func testActionButtonPotentialCarbEntry() async { - await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: .add(mockPotentialCarbEntry)) XCTAssertEqual(.saveWithoutBolusing, bolusEntryViewModel.actionButtonAction) } func testActionButtonManualGlucoseAndPotentialCarbEntry() async { - await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: .add(mockPotentialCarbEntry)) bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity XCTAssertEqual(.saveWithoutBolusing, bolusEntryViewModel.actionButtonAction) } @@ -801,13 +819,13 @@ class BolusEntryViewModelTests: XCTestCase { } func testActionButtonSaveAndDeliverPotentialCarbEntry() async { - await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: .add(mockPotentialCarbEntry)) bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity XCTAssertEqual(.saveAndDeliver, bolusEntryViewModel.actionButtonAction) } func testActionButtonSaveAndDeliverBothManualGlucoseAndPotentialCarbEntry() async { - await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + await setUpViewModel(carbEntryChange: .add(mockPotentialCarbEntry)) bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity XCTAssertEqual(.saveAndDeliver, bolusEntryViewModel.actionButtonAction) @@ -883,10 +901,16 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { return InsulinMath.defaultInsulinActivityDuration } - var carbEntriesAdded = [(NewCarbEntry, StoredCarbEntry?)]() + var carbEntriesAdded = [NewCarbEntry]() + var carbEntriesReplaced = [(StoredCarbEntry, CarbEntryUpdate)]() var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { - carbEntriesAdded.append((carbEntry, replacingEntry)) + func addCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + carbEntriesAdded.append(carbEntry) + return try addCarbEntryResult.get() + } + + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, with update: CarbEntryUpdate) async throws -> StoredCarbEntry { + carbEntriesReplaced.append((oldEntry, update)) switch addCarbEntryResult { case .success(let success): return success @@ -927,8 +951,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { func generatePrediction( - originalCarbEntry: StoredCarbEntry?, - potentialCarbEntry: NewCarbEntry?, + carbEntryChange: CarbEntryChange?, potentialDose: SimpleInsulinDose?, manualGlucose: NewGlucoseSample? ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) { @@ -945,19 +968,16 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { ) var manualGlucoseSampleForBolusRecommendation: NewGlucoseSample? - var potentialCarbEntryForBolusRecommendation: NewCarbEntry? - var originalCarbEntryForBolusRecommendation: StoredCarbEntry? + var carbEntryChangeForBolusRecommendation: CarbEntryChange? func recommendManualBolus( manualGlucoseSample: NewGlucoseSample?, - potentialCarbEntry: NewCarbEntry?, - originalCarbEntry: StoredCarbEntry?, + carbEntryChange: CarbEntryChange?, truncatingActiveOverride: Bool ) async throws -> ManualBolusRecommendation? { manualGlucoseSampleForBolusRecommendation = manualGlucoseSample - potentialCarbEntryForBolusRecommendation = potentialCarbEntry - originalCarbEntryForBolusRecommendation = originalCarbEntry + carbEntryChangeForBolusRecommendation = carbEntryChange switch algorithmOutput.recommendationResult { case .success(let recommendation): @@ -1028,9 +1048,9 @@ extension LoopAlgorithmEffects { } } -extension NewCarbEntry { - static func mock(_ grams: Double, at date: Date) -> NewCarbEntry { - NewCarbEntry( +extension CarbEntryUpdate { + static func mock(_ grams: Double, at date: Date) -> CarbEntryUpdate { + CarbEntryUpdate( quantity: .init(unit: .gram, doubleValue: grams), startDate: date, foodType: nil, diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index 0701b66117..c29861c24a 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -267,14 +267,14 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { return sample.asStoredGlucoseSample } - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { + func addCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { addedCarbEntry = carbEntry let storedCarbEntry = StoredCarbEntry( startDate: carbEntry.startDate, quantity: carbEntry.quantity, uuid: UUID(), provenanceIdentifier: UUID().uuidString, - syncIdentifier: UUID().uuidString, + syncIdentifier: carbEntry.syncIdentifier, syncVersion: 1, foodType: carbEntry.foodType, absorptionTime: carbEntry.absorptionTime, diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 033126dca0..5d913b5996 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -282,7 +282,7 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let missedEntry = NewCarbEntry(quantity: LoopQuantity(unit: .gram, + let missedEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: carbAmount), startDate: mealTime, foodType: nil, diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift index 61fa93b6da..e5d5cb2742 100644 --- a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -114,6 +114,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { func recommendBolus(forGrams grams: Int, eatenAt carbEntryDate: Date, absorptionTime carbAbsorptionTime: CarbAbsorptionTime, lastEntryDate: Date) async { let entry = NewCarbEntry( date: lastEntryDate, + syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: Double(grams)), startDate: carbEntryDate, foodType: carbAbsorptionTime.emoji,