diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 9a11022d6..3162c5b68 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; + B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; + B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; + B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; @@ -96,6 +100,7 @@ DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; + B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000C1 /* QuickPickSectionHeader.swift */; }; DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; }; DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; }; DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; }; @@ -195,8 +200,8 @@ DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0C92D355256000D2A63 /* LogView.swift */; }; DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; }; DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; }; - DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */; }; DDA9ACA62D6A66D000E6F1A9 /* ContactColorMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */; }; + DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */; }; DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */; }; DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */; }; DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */; }; @@ -459,6 +464,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBolusHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000A3 /* QuickPickBolusesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickBolusesManager.swift; sourceTree = ""; }; + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMealHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000B3 /* QuickPickMealsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickMealsManager.swift; sourceTree = ""; }; 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; @@ -545,6 +554,7 @@ DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; + B500000000000000000000C1 /* QuickPickSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickSectionHeader.swift; sourceTree = ""; }; DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; @@ -643,8 +653,8 @@ DD9ED0C92D355256000D2A63 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = ""; }; DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; DD9ED0CD2D355879000D2A63 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = ""; }; - DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorOption.swift; sourceTree = ""; }; DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorMode.swift; sourceTree = ""; }; + DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorOption.swift; sourceTree = ""; }; DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactIncludeOption.swift; sourceTree = ""; }; DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactType.swift; sourceTree = ""; }; DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = ""; }; @@ -1034,9 +1044,29 @@ path = Metric; sourceTree = ""; }; + B500000000000000000000B5 /* QuickPickMeals */ = { + isa = PBXGroup; + children = ( + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */, + B500000000000000000000B3 /* QuickPickMealsManager.swift */, + ); + path = QuickPickMeals; + sourceTree = ""; + }; + B500000000000000000000A5 /* QuickPickBoluses */ = { + isa = PBXGroup; + children = ( + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */, + B500000000000000000000A3 /* QuickPickBolusesManager.swift */, + ); + path = QuickPickBoluses; + sourceTree = ""; + }; DD0C0C6E2C4AFFB800DBADDF /* Remote */ = { isa = PBXGroup; children = ( + B500000000000000000000A5 /* QuickPickBoluses */, + B500000000000000000000B5 /* QuickPickMeals */, DDDF6F4A2D479B6A00884336 /* Nightscout */, DDDF6F482D479AEF00884336 /* NoRemoteView.swift */, DDEF503E2D479B8A00884336 /* LoopAPNS */, @@ -1392,6 +1422,7 @@ DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */, DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */, DD16AF102C997B4600FB655A /* LoadingButtonView.swift */, + B500000000000000000000C1 /* QuickPickSectionHeader.swift */, DDE75D262DE5E539007C1FC1 /* ActionRow.swift */, 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */, DDE75D282DE5E56C007C1FC1 /* LinkRow.swift */, @@ -2160,6 +2191,7 @@ DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */, + B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */, DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */, DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */, FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */, @@ -2186,6 +2218,10 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */, DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */, DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, + B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */, + B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */, + B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */, + B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, diff --git a/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift b/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift new file mode 100644 index 000000000..e966de81d --- /dev/null +++ b/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift @@ -0,0 +1,69 @@ +// LoopFollow +// QuickPickSectionHeader.swift + +import SwiftUI + +struct QuickPickSectionHeader: View { + let title: String + let infoText: String + @State private var showInfo = false + + var body: some View { + HStack(spacing: 4) { + Text(title) + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + .sheet(isPresented: $showInfo) { + QuickPickInfoSheet(title: title, text: infoText) + } + } +} + +private struct QuickPickInfoSheet: View { + let title: String + let text: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + Text(text) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } +} + +extension QuickPickSectionHeader { + static let bolusInfoText = """ + These buttons show your most-used recent bolus amounts. + + They're based on what you've sent before at similar times on similar days — so if you usually give 4 units before breakfast on weekdays, that button will show up on weekday mornings. + + Tap a button to fill in the amount. Nothing is sent until you review and confirm. + """ + + static let mealInfoText = """ + These buttons show your most-used recent meals. + + They're based on what you've sent before at similar times on similar days — so if you usually send the same breakfast on weekday mornings, it'll appear as an option. + + Tap a button to fill in the details. Nothing is sent until you review and confirm. + """ +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 011fe0e10..5c98b6a78 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -13,6 +13,7 @@ struct LoopAPNSBolusView: View { @State private var alertMessage = "" @State private var alertType: AlertType = .success + @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var insulinFieldIsFocused: Bool // Add state for recommended bolus and warning @@ -72,6 +73,30 @@ struct LoopAPNSBolusView: View { } } + if !quickPickBoluses.quickPickBoluses.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickBoluses.quickPickBoluses) { bolus in + Button { + insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: bolus.units) + } label: { + Text("\(InsulinFormatter.shared.string(bolus.units))U") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Insulin Amount", @@ -181,6 +206,10 @@ struct LoopAPNSBolusView: View { showAlert = true } + let step = Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()) + let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + quickPickBoluses.refresh(stepIncrement: max(0.001, step), maxBolus: maxBolus) + loadRecommendedBolus() // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil @@ -369,6 +398,10 @@ struct LoopAPNSBolusView: View { DispatchQueue.main.async { self.isLoading = false if success { + let sentUnits = insulinAmount.doubleValue(for: .internationalUnit()) + if sentUnits > 0 { + QuickPickBolusesManager.shared.recordBolus(units: sentUnits) + } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) self.alertMessage = "Insulin sent successfully!" diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 45e2c403d..e114c639e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -8,6 +8,7 @@ struct LoopAPNSCarbsView: View { private typealias AbsorptionPreset = (hours: Int, minutes: Int) @Environment(\.presentationMode) var presentationMode + @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var absorptionHours = 3 @State private var absorptionMinutes = 0 @@ -102,6 +103,30 @@ struct LoopAPNSCarbsView: View { NavigationView { VStack { Form { + if !quickPickMeals.quickPickMeals.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickMeals.quickPickMeals) { meal in + Button { + carbsAmount = HKQuantity(unit: .gram(), doubleValue: meal.carbs) + } label: { + Text("\(Int(meal.carbs))g") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Carbs Amount", @@ -379,6 +404,12 @@ struct LoopAPNSCarbsView: View { alertType = .error showAlert = true } + + quickPickMeals.refresh( + maxCarbs: Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + includeFatProtein: false + ) + // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil // Don't reset TOTP usage flag here - let the timer handle it @@ -529,6 +560,10 @@ struct LoopAPNSCarbsView: View { DispatchQueue.main.async { self.isLoading = false if success { + let sentCarbs = carbsAmount.doubleValue(for: .gram()) + if sentCarbs > 0 { + QuickPickMealsManager.shared.recordMeal(carbs: sentCarbs) + } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) let timeFormatter = DateFormatter() diff --git a/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift b/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift new file mode 100644 index 000000000..84c2c6ab3 --- /dev/null +++ b/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift @@ -0,0 +1,133 @@ +// LoopFollow +// QuickPickBolusesManager.swift + +import Foundation + +struct QuickPickBolus: Identifiable, Equatable { + let id = UUID() + let units: Double + + static func == (lhs: QuickPickBolus, rhs: QuickPickBolus) -> Bool { + lhs.id == rhs.id + } +} + +final class QuickPickBolusesManager: ObservableObject { + static let shared = QuickPickBolusesManager() + + @Published private(set) var quickPickBoluses: [QuickPickBolus] = [] + + private static let maxEntries = 500 + private static let maxAgeDays = 90.0 + private static let sigma: Double = 60.0 + private static let halfLife: Double = 10.0 + private static let minScore: Double = 0.1 + private static let maxResults = 5 + + private init() {} + + // MARK: - Public API + + func recordBolus(units: Double, at date: Date = Date()) { + let entry = RemoteBolusHistoryEntry(units: units, date: date) + var history = Storage.shared.remoteBolusHistory.value + history.append(entry) + history = Self.pruned(history, now: date) + Storage.shared.remoteBolusHistory.value = history + } + + func refresh(now: Date = Date(), stepIncrement: Double, maxBolus: Double) { + let history = Storage.shared.remoteBolusHistory.value + quickPickBoluses = Self.computeQuickPickBoluses( + from: history, + now: now, + stepIncrement: stepIncrement, + maxBolus: maxBolus + ) + } + + // MARK: - Scoring (static for testability) + + static func computeQuickPickBoluses( + from history: [RemoteBolusHistoryEntry], + now: Date, + stepIncrement: Double, + maxBolus: Double + ) -> [QuickPickBolus] { + guard stepIncrement > 0 else { return [] } + + let nowMinute = { + let cal = Calendar.current + return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + }() + let nowDOW = Calendar.current.component(.weekday, from: now) + + var groups: [Double: Double] = [:] + + for entry in history { + let rounded = (entry.units / stepIncrement).rounded(.down) * stepIncrement + let amount = roundToFraction(rounded, stepIncrement: stepIncrement) + guard amount > 0, amount <= maxBolus else { continue } + + let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute) + let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW) + let daysAgo = now.timeIntervalSince(entry.date) / 86400.0 + let r = recencyScore(daysAgo: daysAgo) + + groups[amount, default: 0] += t * d * r + } + + return groups + .filter { $0.value >= minScore } + .sorted { $0.value > $1.value } + .prefix(maxResults) + .map { QuickPickBolus(units: $0.key) } + } + + static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double { + let diff = abs(entryMinute - nowMinute) + let circularDiff = Double(min(diff, 1440 - diff)) + return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma)) + } + + static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double { + if entryDOW == nowDOW { return 1.0 } + let nowWeekend = nowDOW == 1 || nowDOW == 7 + let entryWeekend = entryDOW == 1 || entryDOW == 7 + if nowWeekend == entryWeekend { return 0.7 } + return 0.15 + } + + static func recencyScore(daysAgo: Double) -> Double { + pow(0.5, daysAgo / halfLife) + } + + // MARK: - Helpers + + private static func pruned(_ history: [RemoteBolusHistoryEntry], now: Date) -> [RemoteBolusHistoryEntry] { + let cutoff = now.addingTimeInterval(-maxAgeDays * 86400) + var filtered = history.filter { $0.date > cutoff } + if filtered.count > maxEntries { + filtered.sort { $0.date > $1.date } + filtered = Array(filtered.prefix(maxEntries)) + } + return filtered + } + + private static func roundToFraction(_ value: Double, stepIncrement: Double) -> Double { + let digits = fractionDigits(for: stepIncrement) + let p = pow(10.0, Double(digits)) + return (value * p).rounded() / p + } + + private static func fractionDigits(for step: Double) -> Int { + if step >= 1 { return 0 } + var v = step + var digits = 0 + while digits < 6, abs(v.rounded() - v) > 1e-10 { + v *= 10 + digits += 1 + } + return min(max(digits, 0), 5) + } +} diff --git a/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift b/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift new file mode 100644 index 000000000..896b7459b --- /dev/null +++ b/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift @@ -0,0 +1,27 @@ +// LoopFollow +// RemoteBolusHistoryEntry.swift + +import Foundation + +/// A record of a remotely-sent bolus, stored locally for pattern-based suggestions. +struct RemoteBolusHistoryEntry: Codable, Equatable { + /// Bolus amount in international units + let units: Double + + /// When the bolus was sent + let date: Date + + /// Day of week: 1=Sunday ... 7=Saturday (Calendar.component(.weekday)) + let dayOfWeek: Int + + /// Minute of day: 0...1439 (hour * 60 + minute) + let minuteOfDay: Int + + init(units: Double, date: Date) { + self.units = units + self.date = date + let cal = Calendar.current + dayOfWeek = cal.component(.weekday, from: date) + minuteOfDay = cal.component(.hour, from: date) * 60 + cal.component(.minute, from: date) + } +} diff --git a/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift b/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift new file mode 100644 index 000000000..cd3f4de25 --- /dev/null +++ b/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift @@ -0,0 +1,139 @@ +// LoopFollow +// QuickPickMealsManager.swift + +import Foundation + +struct QuickPickMeal: Identifiable, Equatable { + let id = UUID() + let carbs: Double + let fat: Double + let protein: Double + let bolus: Double + + static func == (lhs: QuickPickMeal, rhs: QuickPickMeal) -> Bool { + lhs.id == rhs.id + } +} + +final class QuickPickMealsManager: ObservableObject { + static let shared = QuickPickMealsManager() + + @Published private(set) var quickPickMeals: [QuickPickMeal] = [] + + private static let maxEntries = 500 + private static let maxAgeDays = 90.0 + private static let sigma: Double = 60.0 + private static let halfLife: Double = 10.0 + private static let minScore: Double = 0.1 + private static let maxResults = 5 + + private init() {} + + // MARK: - Public API + + func recordMeal(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, at date: Date = Date()) { + let entry = RemoteMealHistoryEntry(carbs: carbs, fat: fat, protein: protein, bolus: bolus, date: date) + var history = Storage.shared.remoteMealHistory.value + history.append(entry) + history = Self.pruned(history, now: date) + Storage.shared.remoteMealHistory.value = history + } + + func refresh(now: Date = Date(), carbStep: Double = 1.0, maxCarbs: Double, includeFatProtein: Bool) { + let history = Storage.shared.remoteMealHistory.value + quickPickMeals = Self.computeQuickPickMeals( + from: history, + now: now, + carbStep: carbStep, + maxCarbs: maxCarbs, + includeFatProtein: includeFatProtein + ) + } + + // MARK: - Scoring (static for testability) + + static func computeQuickPickMeals( + from history: [RemoteMealHistoryEntry], + now: Date, + carbStep: Double, + maxCarbs: Double, + includeFatProtein: Bool + ) -> [QuickPickMeal] { + guard carbStep > 0 else { return [] } + + let nowMinute = { + let cal = Calendar.current + return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + }() + let nowDOW = Calendar.current.component(.weekday, from: now) + + // Group by rounded carbs; track score + best entry (highest scored) for fat/protein + var groupScores: [Double: Double] = [:] + var groupBestEntry: [Double: (entry: RemoteMealHistoryEntry, score: Double)] = [:] + + for entry in history { + let rounded = (entry.carbs / carbStep).rounded() * carbStep + guard rounded > 0, rounded <= maxCarbs else { continue } + + let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute) + let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW) + let daysAgo = now.timeIntervalSince(entry.date) / 86400.0 + let r = recencyScore(daysAgo: daysAgo) + + let score = t * d * r + groupScores[rounded, default: 0] += score + + if let current = groupBestEntry[rounded] { + if score > current.score { + groupBestEntry[rounded] = (entry, score) + } + } else { + groupBestEntry[rounded] = (entry, score) + } + } + + return groupScores + .filter { $0.value >= minScore } + .sorted { $0.value > $1.value } + .prefix(maxResults) + .map { item in + let best = groupBestEntry[item.key]?.entry + return QuickPickMeal( + carbs: item.key, + fat: includeFatProtein ? (best?.fat ?? 0) : 0, + protein: includeFatProtein ? (best?.protein ?? 0) : 0, + bolus: best?.bolus ?? 0 + ) + } + } + + static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double { + let diff = abs(entryMinute - nowMinute) + let circularDiff = Double(min(diff, 1440 - diff)) + return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma)) + } + + static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double { + if entryDOW == nowDOW { return 1.0 } + let nowWeekend = nowDOW == 1 || nowDOW == 7 + let entryWeekend = entryDOW == 1 || entryDOW == 7 + if nowWeekend == entryWeekend { return 0.7 } + return 0.15 + } + + static func recencyScore(daysAgo: Double) -> Double { + pow(0.5, daysAgo / halfLife) + } + + // MARK: - Helpers + + private static func pruned(_ history: [RemoteMealHistoryEntry], now: Date) -> [RemoteMealHistoryEntry] { + let cutoff = now.addingTimeInterval(-maxAgeDays * 86400) + var filtered = history.filter { $0.date > cutoff } + if filtered.count > maxEntries { + filtered.sort { $0.date > $1.date } + filtered = Array(filtered.prefix(maxEntries)) + } + return filtered + } +} diff --git a/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift b/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift new file mode 100644 index 000000000..65ffb1e31 --- /dev/null +++ b/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift @@ -0,0 +1,39 @@ +// LoopFollow +// RemoteMealHistoryEntry.swift + +import Foundation + +/// A record of a remotely-sent meal, stored locally for pattern-based common meals. +struct RemoteMealHistoryEntry: Codable, Equatable { + /// Carbs in grams + let carbs: Double + + /// Fat in grams (0 if not applicable) + let fat: Double + + /// Protein in grams (0 if not applicable) + let protein: Double + + /// Bolus in units (0 if no bolus with meal) + let bolus: Double + + /// When the meal was sent + let date: Date + + /// Day of week: 1=Sunday ... 7=Saturday (Calendar.component(.weekday)) + let dayOfWeek: Int + + /// Minute of day: 0...1439 (hour * 60 + minute) + let minuteOfDay: Int + + init(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, date: Date = Date()) { + self.carbs = carbs + self.fat = fat + self.protein = protein + self.bolus = bolus + self.date = date + let cal = Calendar.current + dayOfWeek = cal.component(.weekday, from: date) + minuteOfDay = cal.component(.hour, from: date) * 60 + cal.component(.minute, from: date) + } +} diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 30bfab213..7255931d9 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -14,6 +14,7 @@ struct BolusView: View { @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested + @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var bolusFieldIsFocused: Bool @State private var showAlert = false @@ -67,6 +68,30 @@ struct BolusView: View { Form { recommendedBlocks(now: context.date) + if !quickPickBoluses.quickPickBoluses.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickBoluses.quickPickBoluses) { bolus in + Button { + applyQuickPickBolus(bolus.units) + } label: { + Text("\(InsulinFormatter.shared.string(bolus.units))U") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Bolus Amount", @@ -105,6 +130,12 @@ struct BolusView: View { .navigationTitle("Bolus") .navigationBarTitleDisplayMode(.inline) } + .onAppear { + quickPickBoluses.refresh( + stepIncrement: stepU, + maxBolus: maxBolus.value.doubleValue(for: .internationalUnit()) + ) + } .alert(isPresented: $showAlert) { switch alertType { case .confirmBolus: @@ -245,6 +276,13 @@ struct BolusView: View { } } + private func applyQuickPickBolus(_ units: Double) { + let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) + let clamped = min(units, maxU) + let stepped = roundedToStep(clamped) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped) + } + private func applyRecommendedBolus(_ rec: Double) { let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) let clamped = min(rec, maxU) @@ -267,6 +305,10 @@ struct BolusView: View { DispatchQueue.main.async { isLoading = false if success { + let sentUnits = bolusAmount.doubleValue(for: .internationalUnit()) + if sentUnits > 0 { + QuickPickBolusesManager.shared.recordBolus(units: sentUnits) + } statusMessage = "Bolus command sent successfully." LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 55dd704e6..9f0e7e9e3 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -20,6 +20,7 @@ struct MealView: View { @ObservedObject private var mealWithBolus = Storage.shared.mealWithBolus @ObservedObject private var mealWithFatProtein = Storage.shared.mealWithFatProtein @ObservedObject private var maxBolus = Storage.shared.maxBolus + @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared @FocusState private var carbsFieldIsFocused: Bool @FocusState private var proteinFieldIsFocused: Bool @@ -46,6 +47,40 @@ struct MealView: View { NavigationView { VStack { Form { + if !quickPickMeals.quickPickMeals.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickMeals.quickPickMeals) { meal in + Button { + applyQuickPickMeal(meal) + } label: { + VStack(spacing: 2) { + Text("\(Int(meal.carbs))g") + .font(.subheadline.weight(.medium)) + if mealWithFatProtein.value, meal.fat > 0 || meal.protein > 0 { + Text("F\(Int(meal.fat)) P\(Int(meal.protein))") + .font(.caption2) + } + if mealWithBolus.value, meal.bolus > 0 { + Text("\(InsulinFormatter.shared.string(meal.bolus))U") + .font(.caption2) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section(header: Text("Meal Data")) { // TODO: This banner can be deleted in March 2027. Check the commit for other places to cleanup. if showFatProteinOrderBanner { @@ -173,6 +208,11 @@ struct MealView: View { selectedTime = nil isScheduling = false + quickPickMeals.refresh( + maxCarbs: maxCarbs.value.doubleValue(for: .gram()), + includeFatProtein: mealWithFatProtein.value + ) + if !Storage.shared.hasSeenFatProteinOrderChange.value && Storage.shared.mealWithFatProtein.value { showFatProteinOrderBanner = true } @@ -300,6 +340,15 @@ struct MealView: View { DispatchQueue.main.async { isLoading = false if success { + let sentCarbs = carbs.doubleValue(for: .gram()) + if sentCarbs > 0 { + QuickPickMealsManager.shared.recordMeal( + carbs: sentCarbs, + fat: fat.doubleValue(for: .gram()), + protein: protein.doubleValue(for: .gram()), + bolus: bolusAmount.doubleValue(for: .internationalUnit()) + ) + } statusMessage = "Meal command sent successfully." LogManager.shared.log( category: .apns, @@ -332,6 +381,21 @@ struct MealView: View { return formatter.string(from: date) } + private func applyQuickPickMeal(_ meal: QuickPickMeal) { + let maxC = maxCarbs.value.doubleValue(for: .gram()) + carbs = HKQuantity(unit: .gram(), doubleValue: min(meal.carbs, maxC)) + if mealWithFatProtein.value { + let maxF = maxFat.value.doubleValue(for: .gram()) + let maxP = maxProtein.value.doubleValue(for: .gram()) + fat = HKQuantity(unit: .gram(), doubleValue: min(meal.fat, maxF)) + protein = HKQuantity(unit: .gram(), doubleValue: min(meal.protein, maxP)) + } + if mealWithBolus.value, meal.bolus > 0 { + let maxB = maxBolus.value.doubleValue(for: .internationalUnit()) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: min(meal.bolus, maxB)) + } + } + private func handleValidationError(_ message: String) { alertMessage = message alertType = .validationError diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 11997da35..009bfb334 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -215,6 +215,9 @@ class Storage { var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) + + var remoteBolusHistory = StorageValue<[RemoteBolusHistoryEntry]>(key: "remoteBolusHistory", defaultValue: []) + var remoteMealHistory = StorageValue<[RemoteMealHistoryEntry]>(key: "remoteMealHistory", defaultValue: []) // Statistics display preferences var showGMI = StorageValue(key: "showGMI", defaultValue: true) var showStdDev = StorageValue(key: "showStdDev", defaultValue: true) @@ -404,6 +407,8 @@ class Storage { loopAPNSQrCodeURL.reload() bolusIncrementDetected.reload() + remoteBolusHistory.reload() + remoteMealHistory.reload() showGMI.reload() showStdDev.reload() showTITR.reload()