فهرست منبع

Quick-Pick Boluses and Meals for remote commands (#603)

* Common remote commands

* Help texts

* Rename Common to Quick-Pick throughout

Renames all files, classes, types, and UI labels introduced by the
quick-pick feature from "Common" to "Quick-Pick":

- CommonBoluses/ → QuickPickBoluses/
- CommonMeals/ → QuickPickMeals/
- CommonBolusesManager → QuickPickBolusesManager
- CommonMealsManager → QuickPickMealsManager
- CommonSectionHeader → QuickPickSectionHeader
- UI labels: "Common Boluses/Meals" → "Quick-Pick Boluses/Meals"
Jonas Björkert 1 ماه پیش
والد
کامیت
566c61a55d

+ 36 - 0
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 */; };
@@ -100,6 +104,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 */; };
 		CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; };
 		DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */; };
@@ -469,6 +474,10 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
+		B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBolusHistoryEntry.swift; sourceTree = "<group>"; };
+		B500000000000000000000A3 /* QuickPickBolusesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickBolusesManager.swift; sourceTree = "<group>"; };
+		B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMealHistoryEntry.swift; sourceTree = "<group>"; };
+		B500000000000000000000B3 /* QuickPickMealsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickMealsManager.swift; sourceTree = "<group>"; };
 		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 = "<group>"; };
 		2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = "<group>"; };
 		2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = "<group>"; };
@@ -559,6 +568,7 @@
 		DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = "<group>"; };
 		DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = "<group>"; };
 		DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = "<group>"; };
+		B500000000000000000000C1 /* QuickPickSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickSectionHeader.swift; sourceTree = "<group>"; };
 		DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = "<group>"; };
 		CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = "<group>"; };
 		DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGDisplayView.swift; sourceTree = "<group>"; };
@@ -1056,9 +1066,29 @@
 			path = Metric;
 			sourceTree = "<group>";
 		};
+		B500000000000000000000B5 /* QuickPickMeals */ = {
+			isa = PBXGroup;
+			children = (
+				B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */,
+				B500000000000000000000B3 /* QuickPickMealsManager.swift */,
+			);
+			path = QuickPickMeals;
+			sourceTree = "<group>";
+		};
+		B500000000000000000000A5 /* QuickPickBoluses */ = {
+			isa = PBXGroup;
+			children = (
+				B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */,
+				B500000000000000000000A3 /* QuickPickBolusesManager.swift */,
+			);
+			path = QuickPickBoluses;
+			sourceTree = "<group>";
+		};
 		DD0C0C6E2C4AFFB800DBADDF /* Remote */ = {
 			isa = PBXGroup;
 			children = (
+				B500000000000000000000A5 /* QuickPickBoluses */,
+				B500000000000000000000B5 /* QuickPickMeals */,
 				DDDF6F4A2D479B6A00884336 /* Nightscout */,
 				DDDF6F482D479AEF00884336 /* NoRemoteView.swift */,
 				DDEF503E2D479B8A00884336 /* LoopAPNS */,
@@ -1414,6 +1444,7 @@
 				DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */,
 				DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */,
 				DD16AF102C997B4600FB655A /* LoadingButtonView.swift */,
+				B500000000000000000000C1 /* QuickPickSectionHeader.swift */,
 				DDE75D262DE5E539007C1FC1 /* ActionRow.swift */,
 				654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */,
 				DDE75D282DE5E56C007C1FC1 /* LinkRow.swift */,
@@ -2189,6 +2220,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 */,
@@ -2215,6 +2247,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 */,

+ 69 - 0
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.
+    """
+}

+ 33 - 0
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",
@@ -186,6 +211,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
@@ -374,6 +403,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!"

+ 35 - 0
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",
@@ -384,6 +409,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
@@ -534,6 +565,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()

+ 133 - 0
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)
+    }
+}

+ 27 - 0
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)
+    }
+}

+ 139 - 0
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
+    }
+}

+ 39 - 0
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)
+    }
+}

+ 42 - 0
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",
@@ -118,6 +143,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:
@@ -258,6 +289,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)
@@ -280,6 +318,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,

+ 64 - 0
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 {
@@ -186,6 +221,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
                 }
@@ -313,6 +353,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,
@@ -345,6 +394,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

+ 5 - 0
LoopFollow/Storage/Storage.swift

@@ -235,6 +235,9 @@ class Storage {
 
     var bolusIncrement = SecureStorageValue<HKQuantity>(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05))
     var bolusIncrementDetected = StorageValue<Bool>(key: "bolusIncrementDetected", defaultValue: false)
+
+    var remoteBolusHistory = StorageValue<[RemoteBolusHistoryEntry]>(key: "remoteBolusHistory", defaultValue: [])
+    var remoteMealHistory = StorageValue<[RemoteMealHistoryEntry]>(key: "remoteMealHistory", defaultValue: [])
     // Statistics display preferences
     var showGMI = StorageValue<Bool>(key: "showGMI", defaultValue: true)
     var showStdDev = StorageValue<Bool>(key: "showStdDev", defaultValue: true)
@@ -429,6 +432,8 @@ class Storage {
 
         loopAPNSQrCodeURL.reload()
         bolusIncrementDetected.reload()
+        remoteBolusHistory.reload()
+        remoteMealHistory.reload()
         showGMI.reload()
         showStdDev.reload()
         showTITR.reload()