فهرست منبع

Redesign carbs remote screen with picker-based absorption time (#567)

* Refactor absorption time handling in LoopAPNSCarbsView to use separate hour and minute states, enhancing clarity and usability

* Redesign date and absorption time pickers to use NavigationStack for improved layout and navigation experience

* Refactor time constraint handling in LoopAPNSCarbsView to use configurable maxPastHours and maxFutureHours constants

* Enhance absorption time handling in LoopAPNSCarbsView with configurable limits and presets

---------

Co-authored-by: codebymini <daniel@codebymini.se>
Jonas Björkert 3 ماه پیش
والد
کامیت
dc363c78b9
1فایلهای تغییر یافته به همراه254 افزوده شده و 89 حذف شده
  1. 254 89
      LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift

+ 254 - 89
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