|
@@ -5,12 +5,16 @@ import HealthKit
|
|
|
import SwiftUI
|
|
import SwiftUI
|
|
|
|
|
|
|
|
struct LoopAPNSCarbsView: View {
|
|
struct LoopAPNSCarbsView: View {
|
|
|
|
|
+ private typealias AbsorptionPreset = (hours: Int, minutes: Int)
|
|
|
|
|
+
|
|
|
@Environment(\.presentationMode) var presentationMode
|
|
@Environment(\.presentationMode) var presentationMode
|
|
|
@State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0)
|
|
@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 foodType = ""
|
|
|
@State private var consumedDate = Date()
|
|
@State private var consumedDate = Date()
|
|
|
@State private var showDatePickerSheet = false
|
|
@State private var showDatePickerSheet = false
|
|
|
|
|
+ @State private var showAbsorptionPickerSheet = false
|
|
|
@State private var isLoading = false
|
|
@State private var isLoading = false
|
|
|
@State private var showAlert = false
|
|
@State private var showAlert = false
|
|
|
@State private var alertMessage = ""
|
|
@State private var alertMessage = ""
|
|
@@ -18,16 +22,76 @@ struct LoopAPNSCarbsView: View {
|
|
|
@State private var otpTimeRemaining: Int? = nil
|
|
@State private var otpTimeRemaining: Int? = nil
|
|
|
@State private var showTOTPWarning = false
|
|
@State private var showTOTPWarning = false
|
|
|
private let otpPeriod: TimeInterval = 30
|
|
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()
|
|
private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
|
|
|
|
|
|
|
@FocusState private var carbsFieldIsFocused: Bool
|
|
@FocusState private var carbsFieldIsFocused: Bool
|
|
|
- @FocusState private var absorptionFieldIsFocused: Bool
|
|
|
|
|
|
|
|
|
|
// Computed property to check if TOTP should be blocked
|
|
// Computed property to check if TOTP should be blocked
|
|
|
private var isTOTPBlocked: Bool {
|
|
private var isTOTPBlocked: Bool {
|
|
|
TOTPService.shared.isTOTPBlocked(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
|
|
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 {
|
|
enum AlertType {
|
|
|
case success
|
|
case success
|
|
|
case error
|
|
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")
|
|
Text("Food Type")
|
|
|
- .font(.headline)
|
|
|
|
|
|
|
|
|
|
- HStack(spacing: 12) {
|
|
|
|
|
- // Fast carb entry emoji (0.5 hours)
|
|
|
|
|
|
|
+ Spacer()
|
|
|
|
|
+
|
|
|
|
|
+ HStack(spacing: 10) {
|
|
|
Button(action: {
|
|
Button(action: {
|
|
|
foodType = "🍭"
|
|
foodType = "🍭"
|
|
|
- absorptionTimeString = "0.5"
|
|
|
|
|
|
|
+ setAbsorptionTime(hours: lollipopStandardAbsorption.hours, minutes: lollipopStandardAbsorption.minutes)
|
|
|
}) {
|
|
}) {
|
|
|
Text("🍭")
|
|
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: {
|
|
Button(action: {
|
|
|
foodType = "🌮"
|
|
foodType = "🌮"
|
|
|
- absorptionTimeString = "3.0"
|
|
|
|
|
|
|
+ setAbsorptionTime(hours: tacoStandardAbsorption.hours, minutes: tacoStandardAbsorption.minutes)
|
|
|
}) {
|
|
}) {
|
|
|
Text("🌮")
|
|
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: {
|
|
Button(action: {
|
|
|
foodType = "🍕"
|
|
foodType = "🍕"
|
|
|
- absorptionTimeString = "5.0"
|
|
|
|
|
|
|
+ setAbsorptionTime(hours: pizzaStandardAbsorption.hours, minutes: pizzaStandardAbsorption.minutes)
|
|
|
}) {
|
|
}) {
|
|
|
Text("🍕")
|
|
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: {
|
|
Button(action: {
|
|
|
foodType = "🍽️"
|
|
foodType = "🍽️"
|
|
|
- absorptionTimeString = ""
|
|
|
|
|
- absorptionFieldIsFocused = true
|
|
|
|
|
|
|
+ showAbsorptionPickerSheet = true
|
|
|
}) {
|
|
}) {
|
|
|
Text("🍽️")
|
|
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")
|
|
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: {
|
|
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 {
|
|
Section {
|
|
@@ -226,16 +296,80 @@ struct LoopAPNSCarbsView: View {
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
}
|
|
}
|
|
|
.sheet(isPresented: $showDatePickerSheet) {
|
|
.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()
|
|
.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 {
|
|
.onAppear {
|
|
|
// Validate APNS setup
|
|
// Validate APNS setup
|
|
@@ -303,7 +437,7 @@ struct LoopAPNSCarbsView: View {
|
|
|
timeFormatter.dateStyle = .short
|
|
timeFormatter.dateStyle = .short
|
|
|
return Alert(
|
|
return Alert(
|
|
|
title: Text("Confirm Carbs"),
|
|
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")) {
|
|
primaryButton: .default(Text("Send")) {
|
|
|
sendCarbsConfirmed()
|
|
sendCarbsConfirmed()
|
|
|
},
|
|
},
|
|
@@ -335,8 +469,6 @@ struct LoopAPNSCarbsView: View {
|
|
|
|
|
|
|
|
// Validate time constraints (similar to LoopCaregiver)
|
|
// Validate time constraints (similar to LoopCaregiver)
|
|
|
let now = Date()
|
|
let now = Date()
|
|
|
- let maxPastHours = 12
|
|
|
|
|
- let maxFutureHours = 1
|
|
|
|
|
let oldestAcceptedDate = now.addingTimeInterval(-60 * 60 * Double(maxPastHours))
|
|
let oldestAcceptedDate = now.addingTimeInterval(-60 * 60 * Double(maxPastHours))
|
|
|
let latestAcceptedDate = now.addingTimeInterval(60 * 60 * Double(maxFutureHours))
|
|
let latestAcceptedDate = now.addingTimeInterval(60 * 60 * Double(maxFutureHours))
|
|
|
|
|
|
|
@@ -370,9 +502,9 @@ struct LoopAPNSCarbsView: View {
|
|
|
return
|
|
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
|
|
alertType = .error
|
|
|
isLoading = false
|
|
isLoading = false
|
|
|
showAlert = true
|
|
showAlert = true
|
|
@@ -386,7 +518,7 @@ struct LoopAPNSCarbsView: View {
|
|
|
let payload = LoopAPNSPayload(
|
|
let payload = LoopAPNSPayload(
|
|
|
type: .carbs,
|
|
type: .carbs,
|
|
|
carbsAmount: carbsAmount.doubleValue(for: .gram()),
|
|
carbsAmount: carbsAmount.doubleValue(for: .gram()),
|
|
|
- absorptionTime: absorptionTimeValue,
|
|
|
|
|
|
|
+ absorptionTime: selectedAbsorptionTime,
|
|
|
foodType: foodType.isEmpty ? nil : foodType,
|
|
foodType: foodType.isEmpty ? nil : foodType,
|
|
|
consumedDate: adjustedConsumedDate,
|
|
consumedDate: adjustedConsumedDate,
|
|
|
otp: otpCode
|
|
otp: otpCode
|
|
@@ -405,7 +537,7 @@ struct LoopAPNSCarbsView: View {
|
|
|
self.alertType = .success
|
|
self.alertType = .success
|
|
|
LogManager.shared.log(
|
|
LogManager.shared.log(
|
|
|
category: .apns,
|
|
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 {
|
|
} else {
|
|
|
self.alertMessage = errorMessage ?? "Failed to send carbs. Check your Loop APNS configuration."
|
|
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
|
|
// APNS Payload structure for carbs
|