diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 28b5745d8..45e2c403d 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -5,12 +5,16 @@ import HealthKit import SwiftUI struct LoopAPNSCarbsView: View { + private typealias AbsorptionPreset = (hours: Int, minutes: Int) + @Environment(\.presentationMode) var presentationMode @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0) - @State private var absorptionTimeString = "3.0" + @State private var absorptionHours = 3 + @State private var absorptionMinutes = 0 @State private var foodType = "" @State private var consumedDate = Date() @State private var showDatePickerSheet = false + @State private var showAbsorptionPickerSheet = false @State private var isLoading = false @State private var showAlert = false @State private var alertMessage = "" @@ -18,16 +22,76 @@ struct LoopAPNSCarbsView: View { @State private var otpTimeRemaining: Int? = nil @State private var showTOTPWarning = false private let otpPeriod: TimeInterval = 30 + private let timeAdjustmentStepMinutes = 5 + private let maxPastHours = 12 + private let maxFutureHours = 1 + private let minAllowedAbsorptionTime = 0.5 + private let maxAllowedAbsorptionTime = 8.0 + private let lollipopStandardAbsorption: AbsorptionPreset = (0, 30) + private let tacoStandardAbsorption: AbsorptionPreset = (3, 0) + private let pizzaStandardAbsorption: AbsorptionPreset = (5, 0) private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @FocusState private var carbsFieldIsFocused: Bool - @FocusState private var absorptionFieldIsFocused: Bool // Computed property to check if TOTP should be blocked private var isTOTPBlocked: Bool { TOTPService.shared.isTOTPBlocked(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) } + private var absorptionTimeValue: Double { + Double(absorptionHours) + (Double(absorptionMinutes) / 60.0) + } + + private var absorptionTimeText: String { + if absorptionMinutes == 0 { + return "\(absorptionHours) hr" + } + + return "\(absorptionHours) hr \(absorptionMinutes) min" + } + + private var absorptionConfirmationText: String { + String(format: "%.1f", absorptionTimeValue) + } + + private var minimumAbsorptionPreset: AbsorptionPreset { + let minimumAbsorptionMinutes = Int(minAllowedAbsorptionTime * 60) + return (minimumAbsorptionMinutes / 60, minimumAbsorptionMinutes % 60) + } + + private var maximumAbsorptionHours: Int { + Int(maxAllowedAbsorptionTime) + } + + private var absorptionValidationMessage: String { + String( + format: "Please enter a valid absorption time between %.1f and %.1f hours", + minAllowedAbsorptionTime, + maxAllowedAbsorptionTime + ) + } + + private var absorptionMinuteOptions: [Int] { + if absorptionHours == minimumAbsorptionPreset.hours { + return [minimumAbsorptionPreset.minutes] + } + + if absorptionHours == maximumAbsorptionHours { + return [0] + } + + return [0, 30] + } + + private var oldestAcceptedDate: Date { + Date().addingTimeInterval(-TimeInterval(maxPastHours) * 60 * 60) + } + + private var latestAcceptedDate: Date { + Date().addingTimeInterval(TimeInterval(maxFutureHours) * 60 * 60) + } + enum AlertType { case success case error @@ -54,112 +118,118 @@ struct LoopAPNSCarbsView: View { } ) - VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Time") + + Spacer() + + Button(action: { + adjustConsumedDate(byMinutes: -timeAdjustmentStepMinutes) + }) { + Image(systemName: "minus") + .font(.subheadline.weight(.semibold)) + .foregroundColor(Color(.systemGray6)) + .frame(width: 28, height: 28) + .background(Color.blue) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Button(action: { + showDatePickerSheet = true + }) { + Text(consumedDate, format: Date.FormatStyle().hour().minute()) + .font(.body.monospacedDigit()) + .foregroundColor(.primary) + .frame(minWidth: 58) + } + .buttonStyle(.plain) + + Button(action: { + adjustConsumedDate(byMinutes: timeAdjustmentStepMinutes) + }) { + Image(systemName: "plus") + .font(.subheadline.weight(.semibold)) + .foregroundColor(Color(.systemGray6)) + .frame(width: 28, height: 28) + .background(Color.blue) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + + HStack(alignment: .center, spacing: 12) { Text("Food Type") - .font(.headline) - HStack(spacing: 12) { - // Fast carb entry emoji (0.5 hours) + Spacer() + + HStack(spacing: 10) { Button(action: { foodType = "🍭" - absorptionTimeString = "0.5" + setAbsorptionTime(hours: lollipopStandardAbsorption.hours, minutes: lollipopStandardAbsorption.minutes) }) { Text("🍭") - .font(.title) - .frame(width: 44, height: 44) - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) + .font(.title3) + .frame(width: 42, height: 42) + .background(foodType == "🍭" ? Color.white.opacity(0.12) : Color.clear) + .cornerRadius(12) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) - // Medium carb entry emoji (3 hours) Button(action: { foodType = "🌮" - absorptionTimeString = "3.0" + setAbsorptionTime(hours: tacoStandardAbsorption.hours, minutes: tacoStandardAbsorption.minutes) }) { Text("🌮") - .font(.title) - .frame(width: 44, height: 44) - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) + .font(.title3) + .frame(width: 42, height: 42) + .background(foodType == "🌮" ? Color.white.opacity(0.12) : Color.clear) + .cornerRadius(12) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) - // Slow carb entry emoji (5 hours) Button(action: { foodType = "🍕" - absorptionTimeString = "5.0" + setAbsorptionTime(hours: pizzaStandardAbsorption.hours, minutes: pizzaStandardAbsorption.minutes) }) { Text("🍕") - .font(.title) - .frame(width: 44, height: 44) - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) + .font(.title3) + .frame(width: 42, height: 42) + .background(foodType == "🍕" ? Color.white.opacity(0.12) : Color.clear) + .cornerRadius(12) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) - // Custom carb entry emoji (clears and focuses absorption) Button(action: { foodType = "🍽️" - absorptionTimeString = "" - absorptionFieldIsFocused = true + showAbsorptionPickerSheet = true }) { Text("🍽️") - .font(.title) - .frame(width: 44, height: 44) - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) + .font(.title3) + .frame(width: 42, height: 42) + .background(foodType == "🍽️" ? Color.white.opacity(0.12) : Color.clear) + .cornerRadius(12) } - .buttonStyle(PlainButtonStyle()) - - Spacer() + .buttonStyle(.plain) } } - HStack { + HStack(spacing: 8) { Text("Absorption Time") - Spacer() - TextField("0.0", text: $absorptionTimeString) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .focused($absorptionFieldIsFocused) - .onChange(of: absorptionTimeString) { newValue in - // Only allow numbers and decimal point - let filtered = newValue.filter { "0123456789.".contains($0) } - // Ensure only one decimal point - let components = filtered.components(separatedBy: ".") - if components.count > 2 { - absorptionTimeString = String(filtered.dropLast()) - } else { - absorptionTimeString = filtered - } - } - Text("hr") - .foregroundColor(.secondary) - } - // Time input section - VStack(alignment: .leading) { - Text("Time") - .font(.headline) + Image(systemName: "info.circle") + .foregroundColor(.blue) + .font(.subheadline) + + Spacer() Button(action: { - showDatePickerSheet = true + showAbsorptionPickerSheet = true }) { - HStack { - Text(consumedDate, format: Date.FormatStyle().hour().minute()) - .font(.body) - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(Color(.systemGray6)) - .cornerRadius(8) + Text(absorptionTimeText) + .foregroundColor(.secondary) } + .buttonStyle(.plain) } } Section { @@ -226,16 +296,80 @@ struct LoopAPNSCarbsView: View { .navigationBarTitleDisplayMode(.inline) } .sheet(isPresented: $showDatePickerSheet) { - VStack { - Text("Consumption Time") - .font(.headline) + NavigationStack { + VStack { + DatePicker( + "Time", + selection: Binding( + get: { consumedDate }, + set: { consumedDate = clampedConsumedDate($0) } + ), + in: oldestAcceptedDate ... latestAcceptedDate, + displayedComponents: [.hourAndMinute, .date] + ) + .datePickerStyle(.wheel) + .labelsHidden() .padding() - Form { - DatePicker("Time", selection: $consumedDate, displayedComponents: [.hourAndMinute, .date]) - .datePickerStyle(.automatic) + + Spacer() + } + .navigationTitle("Consumption Time") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + consumedDate = clampedConsumedDate(consumedDate) + showDatePickerSheet = false + } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showAbsorptionPickerSheet) { + NavigationStack { + VStack { + HStack(spacing: 0) { + Picker("Hours", selection: $absorptionHours) { + ForEach(0 ... maximumAbsorptionHours, id: \.self) { hour in + Text("\(hour) hr") + .tag(hour) + } + } + + Picker("Minutes", selection: $absorptionMinutes) { + ForEach(absorptionMinuteOptions, id: \.self) { minute in + Text("\(minute) min") + .tag(minute) + } + } + } + .pickerStyle(.wheel) + .frame(height: 180) + .padding(.horizontal) + + Spacer() + } + .navigationTitle("Absorption Time") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + normalizeAbsorptionTime() + showAbsorptionPickerSheet = false + } + } } } - .presentationDetents([.fraction(1 / 4)]) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + .onAppear { + normalizeAbsorptionTime() + } + .onChange(of: absorptionHours) { _ in + normalizeAbsorptionTime() + } } .onAppear { // Validate APNS setup @@ -303,7 +437,7 @@ struct LoopAPNSCarbsView: View { timeFormatter.dateStyle = .short return Alert( title: Text("Confirm Carbs"), - message: Text("Send \(Int(carbsAmount.doubleValue(for: .gram())))g of carbs with \(absorptionTimeString)h absorption time at \(timeFormatter.string(from: consumedDate))?"), + message: Text("Send \(Int(carbsAmount.doubleValue(for: .gram())))g of carbs with \(absorptionConfirmationText)h absorption time at \(timeFormatter.string(from: consumedDate))?"), primaryButton: .default(Text("Send")) { sendCarbsConfirmed() }, @@ -335,8 +469,6 @@ struct LoopAPNSCarbsView: View { // Validate time constraints (similar to LoopCaregiver) let now = Date() - let maxPastHours = 12 - let maxFutureHours = 1 let oldestAcceptedDate = now.addingTimeInterval(-60 * 60 * Double(maxPastHours)) let latestAcceptedDate = now.addingTimeInterval(60 * 60 * Double(maxFutureHours)) @@ -370,9 +502,9 @@ struct LoopAPNSCarbsView: View { return } - // Parse absorption time string to double - guard let absorptionTimeValue = Double(absorptionTimeString), absorptionTimeValue >= 0.5, absorptionTimeValue <= 8.0 else { - alertMessage = "Please enter a valid absorption time between 0.5 and 8.0 hours" + let selectedAbsorptionTime = absorptionTimeValue + guard selectedAbsorptionTime >= minAllowedAbsorptionTime, selectedAbsorptionTime <= maxAllowedAbsorptionTime else { + alertMessage = absorptionValidationMessage alertType = .error isLoading = false showAlert = true @@ -386,7 +518,7 @@ struct LoopAPNSCarbsView: View { let payload = LoopAPNSPayload( type: .carbs, carbsAmount: carbsAmount.doubleValue(for: .gram()), - absorptionTime: absorptionTimeValue, + absorptionTime: selectedAbsorptionTime, foodType: foodType.isEmpty ? nil : foodType, consumedDate: adjustedConsumedDate, otp: otpCode @@ -405,7 +537,7 @@ struct LoopAPNSCarbsView: View { self.alertType = .success LogManager.shared.log( category: .apns, - message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTimeString)h, Time: \(adjustedConsumedDate)" + message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionConfirmationText)h, Time: \(adjustedConsumedDate)" ) } else { self.alertMessage = errorMessage ?? "Failed to send carbs. Check your Loop APNS configuration." @@ -419,6 +551,39 @@ struct LoopAPNSCarbsView: View { } } } + + private func setAbsorptionTime(hours: Int, minutes: Int) { + absorptionHours = min(max(hours, 0), maximumAbsorptionHours) + absorptionMinutes = minutes + normalizeAbsorptionTime() + } + + private func normalizeAbsorptionTime() { + if absorptionTimeValue < minAllowedAbsorptionTime { + absorptionHours = minimumAbsorptionPreset.hours + absorptionMinutes = minimumAbsorptionPreset.minutes + return + } + + if absorptionTimeValue >= maxAllowedAbsorptionTime { + absorptionHours = maximumAbsorptionHours + absorptionMinutes = 0 + return + } + + if !absorptionMinuteOptions.contains(absorptionMinutes) { + absorptionMinutes = absorptionMinuteOptions.first ?? 0 + } + } + + private func adjustConsumedDate(byMinutes minutes: Int) { + let adjustedDate = Calendar.current.date(byAdding: .minute, value: minutes, to: consumedDate) ?? consumedDate + consumedDate = clampedConsumedDate(adjustedDate) + } + + private func clampedConsumedDate(_ date: Date) -> Date { + min(max(date, oldestAcceptedDate), latestAcceptedDate) + } } // APNS Payload structure for carbs