|
@@ -7,36 +7,73 @@ import SwiftUI
|
|
|
|
|
|
|
|
struct BolusView: View {
|
|
struct BolusView: View {
|
|
|
@Environment(\.presentationMode) private var presentationMode
|
|
@Environment(\.presentationMode) private var presentationMode
|
|
|
|
|
+
|
|
|
@State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
|
|
@State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
|
|
|
- private let pushNotificationManager = PushNotificationManager()
|
|
|
|
|
@ObservedObject private var maxBolus = Storage.shared.maxBolus
|
|
@ObservedObject private var maxBolus = Storage.shared.maxBolus
|
|
|
|
|
+ @ObservedObject private var bolusIncrement = Storage.shared.bolusIncrement
|
|
|
|
|
|
|
|
- @FocusState private var bolusFieldIsFocused: Bool
|
|
|
|
|
|
|
+ @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus
|
|
|
|
|
+ @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested
|
|
|
|
|
|
|
|
|
|
+ @FocusState private var bolusFieldIsFocused: Bool
|
|
|
@State private var showAlert = false
|
|
@State private var showAlert = false
|
|
|
@State private var alertType: AlertType? = nil
|
|
@State private var alertType: AlertType? = nil
|
|
|
@State private var alertMessage: String? = nil
|
|
@State private var alertMessage: String? = nil
|
|
|
@State private var isLoading = false
|
|
@State private var isLoading = false
|
|
|
@State private var statusMessage: String? = nil
|
|
@State private var statusMessage: String? = nil
|
|
|
|
|
|
|
|
|
|
+ private let pushNotificationManager = PushNotificationManager()
|
|
|
|
|
+
|
|
|
enum AlertType {
|
|
enum AlertType {
|
|
|
case confirmBolus
|
|
case confirmBolus
|
|
|
case statusSuccess
|
|
case statusSuccess
|
|
|
case statusFailure
|
|
case statusFailure
|
|
|
case validation
|
|
case validation
|
|
|
|
|
+ case oldCalculationWarning
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Step/precision helpers driven by stored increment
|
|
|
|
|
+
|
|
|
|
|
+ private var stepU: Double {
|
|
|
|
|
+ max(0.001, bolusIncrement.value.doubleValue(for: .internationalUnit()))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private var stepFractionDigits: Int {
|
|
|
|
|
+ let inc = stepU
|
|
|
|
|
+ if inc >= 1 { return 0 }
|
|
|
|
|
+ var v = inc
|
|
|
|
|
+ var digits = 0
|
|
|
|
|
+ while digits < 6 && abs(round(v) - v) > 1e-10 {
|
|
|
|
|
+ v *= 10; digits += 1
|
|
|
|
|
+ }
|
|
|
|
|
+ return min(max(digits, 0), 5)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func roundedToStep(_ value: Double) -> Double {
|
|
|
|
|
+ guard stepU > 0 else { return value }
|
|
|
|
|
+
|
|
|
|
|
+ let epsilon = 1e-10
|
|
|
|
|
+ let stepped = ((value / stepU) + epsilon).rounded(.down) * stepU
|
|
|
|
|
+
|
|
|
|
|
+ let p = pow(10.0, Double(stepFractionDigits))
|
|
|
|
|
+ return (stepped * p).rounded() / p
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - View
|
|
|
|
|
+
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
NavigationView {
|
|
NavigationView {
|
|
|
- VStack {
|
|
|
|
|
|
|
+ TimelineView(.periodic(from: .now, by: 1)) { context in
|
|
|
Form {
|
|
Form {
|
|
|
|
|
+ recommendedBlocks(now: context.date)
|
|
|
|
|
+
|
|
|
Section {
|
|
Section {
|
|
|
HKQuantityInputView(
|
|
HKQuantityInputView(
|
|
|
label: "Bolus Amount",
|
|
label: "Bolus Amount",
|
|
|
quantity: $bolusAmount,
|
|
quantity: $bolusAmount,
|
|
|
unit: .internationalUnit(),
|
|
unit: .internationalUnit(),
|
|
|
- maxLength: 4,
|
|
|
|
|
- minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05),
|
|
|
|
|
|
|
+ maxLength: 5,
|
|
|
|
|
+ minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
|
|
|
maxValue: maxBolus.value,
|
|
maxValue: maxBolus.value,
|
|
|
isFocused: $bolusFieldIsFocused,
|
|
isFocused: $bolusFieldIsFocused,
|
|
|
onValidationError: { message in
|
|
onValidationError: { message in
|
|
@@ -52,7 +89,11 @@ struct BolusView: View {
|
|
|
action: {
|
|
action: {
|
|
|
bolusFieldIsFocused = false
|
|
bolusFieldIsFocused = false
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
|
- if bolusAmount.doubleValue(for: HKUnit.internationalUnit()) > 0.0 {
|
|
|
|
|
|
|
+ let rawValue = self.bolusAmount.doubleValue(for: .internationalUnit())
|
|
|
|
|
+ let steppedAmount = roundedToStep(rawValue)
|
|
|
|
|
+
|
|
|
|
|
+ if steppedAmount > 0 {
|
|
|
|
|
+ bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: steppedAmount)
|
|
|
alertType = .confirmBolus
|
|
alertType = .confirmBolus
|
|
|
showAlert = true
|
|
showAlert = true
|
|
|
}
|
|
}
|
|
@@ -69,7 +110,7 @@ struct BolusView: View {
|
|
|
case .confirmBolus:
|
|
case .confirmBolus:
|
|
|
return Alert(
|
|
return Alert(
|
|
|
title: Text("Confirm Bolus"),
|
|
title: Text("Confirm Bolus"),
|
|
|
- message: Text("Are you sure you want to send \(bolusAmount.doubleValue(for: HKUnit.internationalUnit()), specifier: "%.2f") U?"),
|
|
|
|
|
|
|
+ message: Text("Are you sure you want to send \(InsulinFormatter.shared.string(bolusAmount)) U?"),
|
|
|
primaryButton: .default(Text("Confirm"), action: {
|
|
primaryButton: .default(Text("Confirm"), action: {
|
|
|
AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
|
|
AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
|
|
|
if case .success = result {
|
|
if case .success = result {
|
|
@@ -99,6 +140,17 @@ struct BolusView: View {
|
|
|
message: Text(alertMessage ?? "Invalid input."),
|
|
message: Text(alertMessage ?? "Invalid input."),
|
|
|
dismissButton: .default(Text("OK"))
|
|
dismissButton: .default(Text("OK"))
|
|
|
)
|
|
)
|
|
|
|
|
+ case .oldCalculationWarning:
|
|
|
|
|
+ return Alert(
|
|
|
|
|
+ title: Text("Old Calculation Warning"),
|
|
|
|
|
+ message: Text(alertMessage ?? ""),
|
|
|
|
|
+ primaryButton: .default(Text("Use Anyway")) {
|
|
|
|
|
+ if let rec = deviceRecBolus.value {
|
|
|
|
|
+ applyRecommendedBolus(rec)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ secondaryButton: .cancel()
|
|
|
|
|
+ )
|
|
|
case .none:
|
|
case .none:
|
|
|
return Alert(title: Text("Unknown Alert"))
|
|
return Alert(title: Text("Unknown Alert"))
|
|
|
}
|
|
}
|
|
@@ -106,20 +158,114 @@ struct BolusView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // MARK: - Recommended bolus UI
|
|
|
|
|
+
|
|
|
|
|
+ @ViewBuilder
|
|
|
|
|
+ private func recommendedBlocks(now: Date) -> some View {
|
|
|
|
|
+ if let rec = deviceRecBolus.value,
|
|
|
|
|
+ let t = enactedOrSuggested.value
|
|
|
|
|
+ {
|
|
|
|
|
+ let ageSec = max(0, now.timeIntervalSince1970 - t)
|
|
|
|
|
+
|
|
|
|
|
+ if ageSec < 12 * 60 {
|
|
|
|
|
+ let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
|
|
|
|
|
+ let clamped = min(rec, maxU)
|
|
|
|
|
+ let steppedRec = roundedToStep(clamped)
|
|
|
|
|
+
|
|
|
|
|
+ if steppedRec > 0 {
|
|
|
|
|
+ let mins = Int(ageSec / 60)
|
|
|
|
|
+ let isStale5 = ageSec >= 5 * 60
|
|
|
|
|
+
|
|
|
|
|
+ Section(header: Text("Recommended Bolus")) {
|
|
|
|
|
+ Button {
|
|
|
|
|
+ handleRecommendedBolusTap(rec: steppedRec, ageSec: ageSec)
|
|
|
|
|
+ } label: {
|
|
|
|
|
+ HStack {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
+ Text("\(InsulinFormatter.shared.string(steppedRec))U")
|
|
|
|
|
+ Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago")
|
|
|
|
|
+ .font(.caption)
|
|
|
|
|
+ .foregroundColor(.secondary)
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer()
|
|
|
|
|
+ Image(systemName: "arrow.up.circle.fill")
|
|
|
|
|
+ .font(.title2)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.vertical, 8)
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(PlainButtonStyle())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Section {
|
|
|
|
|
+ let color: Color = isStale5 ? .red : .yellow
|
|
|
|
|
+ Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago.")
|
|
|
|
|
+ .font(.callout)
|
|
|
|
|
+ .foregroundColor(color)
|
|
|
|
|
+ .multilineTextAlignment(.leading)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } else {
|
|
|
|
|
+ EmptyView()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } else {
|
|
|
|
|
+ EmptyView()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } else {
|
|
|
|
|
+ EmptyView()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func handleRecommendedBolusTap(rec: Double, ageSec: TimeInterval) {
|
|
|
|
|
+ let isStale5 = ageSec >= 5 * 60
|
|
|
|
|
+ let isStale12 = ageSec >= 12 * 60
|
|
|
|
|
+ if isStale12 { return }
|
|
|
|
|
+ if isStale5 {
|
|
|
|
|
+ let mins = Int(ageSec / 60)
|
|
|
|
|
+ alertMessage = "This recommended bolus was calculated \(mins) minutes ago. New treatments may have occurred since then. Proceed with caution."
|
|
|
|
|
+ alertType = .oldCalculationWarning
|
|
|
|
|
+ showAlert = true
|
|
|
|
|
+ } else {
|
|
|
|
|
+ applyRecommendedBolus(rec)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyRecommendedBolus(_ rec: Double) {
|
|
|
|
|
+ let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
|
|
|
|
|
+ let clamped = min(rec, maxU)
|
|
|
|
|
+ let stepped = roundedToStep(clamped)
|
|
|
|
|
+ bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func presentableMinutesFormat(timeInterval: TimeInterval) -> String {
|
|
|
|
|
+ let minutes = max(0, Int(timeInterval / 60))
|
|
|
|
|
+ var s = "\(minutes) minute"
|
|
|
|
|
+ if minutes == 0 || minutes > 1 { s += "s" }
|
|
|
|
|
+ return s
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Send
|
|
|
|
|
+
|
|
|
private func sendBolus() {
|
|
private func sendBolus() {
|
|
|
isLoading = true
|
|
isLoading = true
|
|
|
-
|
|
|
|
|
pushNotificationManager.sendBolusPushNotification(bolusAmount: bolusAmount) { success, errorMessage in
|
|
pushNotificationManager.sendBolusPushNotification(bolusAmount: bolusAmount) { success, errorMessage in
|
|
|
DispatchQueue.main.async {
|
|
DispatchQueue.main.async {
|
|
|
isLoading = false
|
|
isLoading = false
|
|
|
if success {
|
|
if success {
|
|
|
statusMessage = "Bolus command sent successfully."
|
|
statusMessage = "Bolus command sent successfully."
|
|
|
- LogManager.shared.log(category: .apns, message: "sendBolusPushNotification succeeded - Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U")
|
|
|
|
|
|
|
+ LogManager.shared.log(
|
|
|
|
|
+ category: .apns,
|
|
|
|
|
+ message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U"
|
|
|
|
|
+ )
|
|
|
bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
|
|
bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
|
|
|
alertType = .statusSuccess
|
|
alertType = .statusSuccess
|
|
|
} else {
|
|
} else {
|
|
|
statusMessage = errorMessage ?? "Failed to send bolus command."
|
|
statusMessage = errorMessage ?? "Failed to send bolus command."
|
|
|
- LogManager.shared.log(category: .apns, message: "sendBolusPushNotification failed with error: \(errorMessage ?? "unknown error")")
|
|
|
|
|
|
|
+ LogManager.shared.log(
|
|
|
|
|
+ category: .apns,
|
|
|
|
|
+ message: "sendBolusPushNotification failed with error: \(errorMessage ?? "unknown error")"
|
|
|
|
|
+ )
|
|
|
alertType = .statusFailure
|
|
alertType = .statusFailure
|
|
|
}
|
|
}
|
|
|
showAlert = true
|
|
showAlert = true
|