OverridePresetsView.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. // LoopFollow
  2. // OverridePresetsView.swift
  3. import SwiftUI
  4. struct OverridePresetsView: View {
  5. @StateObject private var viewModel = OverridePresetsViewModel()
  6. @Environment(\.presentationMode) var presentationMode
  7. @ObservedObject var overrideNote = Observable.shared.override
  8. @ObservedObject var overrideEndAt = Observable.shared.overrideEndAt
  9. var body: some View {
  10. NavigationView {
  11. VStack {
  12. List {
  13. // Current Active Override Section
  14. if let activeNote = overrideNote.value {
  15. Section(header: Text("Active Override")) {
  16. HStack {
  17. Image(systemName: "checkmark.circle.fill")
  18. .foregroundColor(.green)
  19. .font(.title2)
  20. VStack(alignment: .leading, spacing: 4) {
  21. Text("Active Override")
  22. .font(.headline)
  23. .foregroundColor(.primary)
  24. Text(activeNote)
  25. .font(.subheadline)
  26. .foregroundColor(.secondary)
  27. if let endAt = overrideEndAt.value {
  28. RemainingTimeText(endAt: endAt)
  29. .font(.subheadline)
  30. .foregroundColor(.secondary)
  31. }
  32. }
  33. Spacer()
  34. }
  35. .padding(.vertical, 4)
  36. Button(action: {
  37. viewModel.alertType = .confirmCancellation
  38. viewModel.showAlert = true
  39. }) {
  40. HStack {
  41. Image(systemName: "xmark.circle")
  42. .foregroundColor(.red)
  43. Text("Cancel Active Override")
  44. .foregroundColor(.red)
  45. }
  46. }
  47. }
  48. }
  49. Section(header: Text("Available Overrides")) {
  50. if viewModel.isLoading {
  51. HStack {
  52. ProgressView()
  53. .scaleEffect(0.8)
  54. Text("Loading override presets...")
  55. .foregroundColor(.secondary)
  56. }
  57. } else if viewModel.overridePresets.isEmpty {
  58. Text("No override presets found. Configure presets in your Loop app.")
  59. .foregroundColor(.secondary)
  60. .italic()
  61. } else {
  62. ForEach(viewModel.overridePresets, id: \.name) { preset in
  63. OverridePresetRow(
  64. preset: preset,
  65. isActivating: viewModel.isActivating && viewModel.selectedPreset?.name == preset.name,
  66. onActivate: {
  67. viewModel.selectedPreset = preset
  68. viewModel.showOverrideModal = true
  69. }
  70. )
  71. }
  72. }
  73. }
  74. }
  75. if viewModel.isActivating {
  76. ProgressView("Please wait...")
  77. .padding()
  78. }
  79. }
  80. .navigationBarTitle("Remote Overrides", displayMode: .inline)
  81. .onAppear {
  82. Task {
  83. await viewModel.loadOverridePresets()
  84. }
  85. }
  86. .sheet(isPresented: $viewModel.showOverrideModal) {
  87. if let preset = viewModel.selectedPreset {
  88. OverrideActivationModal(
  89. preset: preset,
  90. onActivate: { duration in
  91. viewModel.showOverrideModal = false
  92. Task {
  93. await viewModel.activateOverride(preset: preset, duration: duration)
  94. }
  95. },
  96. onCancel: {
  97. viewModel.showOverrideModal = false
  98. }
  99. )
  100. }
  101. }
  102. .alert(isPresented: $viewModel.showAlert) {
  103. switch viewModel.alertType {
  104. case .confirmCancellation:
  105. return Alert(
  106. title: Text("Cancel Override"),
  107. message: Text("Are you sure you want to cancel the active override?"),
  108. primaryButton: .default(Text("Confirm"), action: {
  109. Task {
  110. await viewModel.cancelOverride()
  111. }
  112. }),
  113. secondaryButton: .cancel()
  114. )
  115. case .statusSuccess:
  116. return Alert(
  117. title: Text("Success"),
  118. message: Text(viewModel.statusMessage ?? ""),
  119. dismissButton: .default(Text("OK"), action: {
  120. presentationMode.wrappedValue.dismiss()
  121. })
  122. )
  123. case .statusFailure:
  124. return Alert(
  125. title: Text("Error"),
  126. message: Text(viewModel.statusMessage ?? "An error occurred."),
  127. dismissButton: .default(Text("OK"))
  128. )
  129. case .none:
  130. return Alert(title: Text("Unknown Alert"))
  131. }
  132. }
  133. }
  134. }
  135. }
  136. struct OverridePresetRow: View {
  137. let preset: OverridePreset
  138. let isActivating: Bool
  139. let onActivate: () -> Void
  140. var body: some View {
  141. Button(action: onActivate) {
  142. HStack {
  143. if let symbol = preset.symbol {
  144. Text(symbol)
  145. .font(.title2)
  146. }
  147. VStack(alignment: .leading, spacing: 4) {
  148. Text(preset.name)
  149. .font(.headline)
  150. .foregroundColor(.primary)
  151. HStack(spacing: 8) {
  152. if let targetRange = preset.targetRange {
  153. Text("Target: \(Localizer.formatLocalDouble(targetRange.lowerBound))-\(Localizer.formatLocalDouble(targetRange.upperBound))")
  154. .font(.caption)
  155. .foregroundColor(.secondary)
  156. }
  157. if let insulinNeedsScaleFactor = preset.insulinNeedsScaleFactor {
  158. Text("Insulin: \(Int(insulinNeedsScaleFactor * 100))%")
  159. .font(.caption)
  160. .foregroundColor(.secondary)
  161. }
  162. Text("Duration: \(preset.durationDescription)")
  163. .font(.caption)
  164. .foregroundColor(.secondary)
  165. }
  166. }
  167. Spacer()
  168. if isActivating {
  169. ProgressView()
  170. .scaleEffect(0.8)
  171. .foregroundColor(.blue)
  172. } else {
  173. Image(systemName: "chevron.right")
  174. .foregroundColor(.secondary)
  175. .font(.caption)
  176. }
  177. }
  178. .padding(.vertical, 4)
  179. }
  180. .buttonStyle(PlainButtonStyle())
  181. .disabled(isActivating)
  182. }
  183. }
  184. struct OverrideActivationModal: View {
  185. let preset: OverridePreset
  186. let onActivate: (TimeInterval?) -> Void
  187. let onCancel: () -> Void
  188. @State private var enableIndefinitely: Bool
  189. @State private var durationHours: Double = 1.0
  190. init(preset: OverridePreset, onActivate: @escaping (TimeInterval?) -> Void, onCancel: @escaping () -> Void) {
  191. self.preset = preset
  192. self.onActivate = onActivate
  193. self.onCancel = onCancel
  194. // Initialize state based on preset duration
  195. if preset.duration == 0 {
  196. // Indefinite override - allow user to choose
  197. _enableIndefinitely = State(initialValue: true)
  198. } else {
  199. // Override with predefined duration - use preset duration
  200. _enableIndefinitely = State(initialValue: false)
  201. _durationHours = State(initialValue: preset.duration / 3600)
  202. }
  203. }
  204. var body: some View {
  205. NavigationView {
  206. VStack(spacing: 20) {
  207. // Preset Info
  208. VStack(spacing: 12) {
  209. if let symbol = preset.symbol {
  210. Text(symbol)
  211. .font(.largeTitle)
  212. }
  213. Text(preset.name)
  214. .font(.title2)
  215. .fontWeight(.semibold)
  216. .multilineTextAlignment(.center)
  217. if let targetRange = preset.targetRange {
  218. Text("Target: \(Localizer.formatLocalDouble(targetRange.lowerBound))-\(Localizer.formatLocalDouble(targetRange.upperBound))")
  219. .font(.subheadline)
  220. .foregroundColor(.secondary)
  221. }
  222. if let insulinNeedsScaleFactor = preset.insulinNeedsScaleFactor {
  223. Text("Insulin: \(Int(insulinNeedsScaleFactor * 100))%")
  224. .font(.subheadline)
  225. .foregroundColor(.secondary)
  226. }
  227. // Only show duration for overrides with predefined duration
  228. if preset.duration != 0 {
  229. Text("Duration: \(preset.durationDescription)")
  230. .font(.subheadline)
  231. .foregroundColor(.secondary)
  232. }
  233. }
  234. .padding(.top)
  235. Spacer()
  236. // Duration Settings (only show for overrides without predefined duration)
  237. if preset.duration == 0 {
  238. VStack(spacing: 16) {
  239. // Duration Input (only show when not indefinite)
  240. if !enableIndefinitely {
  241. VStack(spacing: 8) {
  242. HStack {
  243. Text("Duration")
  244. .font(.headline)
  245. Spacer()
  246. Text(formatDuration(durationHours))
  247. .font(.headline)
  248. .foregroundColor(.blue)
  249. }
  250. Slider(value: $durationHours, in: 0.25 ... 24.0, step: 0.25)
  251. .accentColor(.blue)
  252. HStack {
  253. Text("15m")
  254. .font(.caption)
  255. .foregroundColor(.secondary)
  256. .frame(width: 80, alignment: .leading)
  257. Spacer()
  258. Text("24h")
  259. .font(.caption)
  260. .foregroundColor(.secondary)
  261. .frame(width: 80, alignment: .trailing)
  262. }
  263. }
  264. .padding(.horizontal)
  265. }
  266. // Indefinitely Toggle
  267. HStack {
  268. Toggle("Enable indefinitely", isOn: $enableIndefinitely)
  269. Spacer()
  270. }
  271. .padding(.horizontal)
  272. }
  273. }
  274. // Action Buttons
  275. VStack(spacing: 12) {
  276. Button(action: {
  277. let duration: TimeInterval?
  278. if preset.duration == 0 {
  279. // For indefinite overrides, use user selection
  280. duration = enableIndefinitely ? nil : (durationHours * 3600)
  281. } else {
  282. // For overrides with predefined duration, use preset duration
  283. duration = preset.duration
  284. }
  285. onActivate(duration)
  286. }) {
  287. Text("Activate Override")
  288. .font(.headline)
  289. .foregroundColor(.white)
  290. .frame(maxWidth: .infinity)
  291. .padding()
  292. .background(Color.blue)
  293. .cornerRadius(10)
  294. }
  295. Button(action: onCancel) {
  296. Text("Cancel")
  297. .font(.headline)
  298. .foregroundColor(.secondary)
  299. .frame(maxWidth: .infinity)
  300. .padding()
  301. .background(Color.gray.opacity(0.2))
  302. .cornerRadius(10)
  303. }
  304. }
  305. .padding(.horizontal)
  306. .padding(.bottom)
  307. }
  308. .navigationBarTitle("Activate Override", displayMode: .inline)
  309. .navigationBarTitleDisplayMode(.inline)
  310. .toolbar {
  311. ToolbarItem(placement: .navigationBarTrailing) {
  312. Button("Cancel") {
  313. onCancel()
  314. }
  315. }
  316. }
  317. }
  318. }
  319. // Helper function to format duration in hours and minutes
  320. private func formatDuration(_ hours: Double) -> String {
  321. let totalMinutes = Int(hours * 60)
  322. let hours = totalMinutes / 60
  323. let minutes = totalMinutes % 60
  324. if hours > 0 && minutes > 0 {
  325. return "\(hours)h \(minutes)m"
  326. } else if hours > 0 {
  327. return "\(hours)h"
  328. } else {
  329. return "\(minutes)m"
  330. }
  331. }
  332. }
  333. class OverridePresetsViewModel: ObservableObject {
  334. @Published var overridePresets: [OverridePreset] = []
  335. @Published var isLoading = false
  336. @Published var isActivating = false
  337. @Published var showAlert = false
  338. @Published var alertType: AlertType? = nil
  339. @Published var statusMessage: String? = nil
  340. @Published var selectedPreset: OverridePreset? = nil
  341. @Published var showOverrideModal = false
  342. enum AlertType {
  343. case confirmCancellation
  344. case statusSuccess
  345. case statusFailure
  346. }
  347. func loadOverridePresets() async {
  348. await MainActor.run {
  349. isLoading = true
  350. }
  351. do {
  352. let presets = try await fetchOverridePresetsFromStorage()
  353. await MainActor.run {
  354. self.overridePresets = presets
  355. self.isLoading = false
  356. }
  357. } catch {
  358. await MainActor.run {
  359. self.statusMessage = "Failed to load override presets: \(error.localizedDescription)"
  360. self.alertType = .statusFailure
  361. self.showAlert = true
  362. self.isLoading = false
  363. }
  364. }
  365. }
  366. func activateOverride(preset: OverridePreset, duration: TimeInterval?) async {
  367. await MainActor.run {
  368. isActivating = true
  369. }
  370. do {
  371. try await sendOverrideNotification(preset: preset, duration: duration)
  372. await MainActor.run {
  373. self.isActivating = false
  374. self.statusMessage = "\(preset.name) override activated successfully."
  375. self.alertType = .statusSuccess
  376. self.showAlert = true
  377. }
  378. } catch {
  379. await MainActor.run {
  380. self.isActivating = false
  381. self.statusMessage = "Failed to activate override: \(error.localizedDescription)"
  382. self.alertType = .statusFailure
  383. self.showAlert = true
  384. }
  385. }
  386. }
  387. func cancelOverride() async {
  388. await MainActor.run {
  389. isActivating = true
  390. }
  391. do {
  392. try await sendCancelOverrideNotification()
  393. await MainActor.run {
  394. self.isActivating = false
  395. self.statusMessage = "Active override cancelled successfully."
  396. self.alertType = .statusSuccess
  397. self.showAlert = true
  398. }
  399. } catch {
  400. await MainActor.run {
  401. self.isActivating = false
  402. self.statusMessage = "Failed to cancel override: \(error.localizedDescription)"
  403. self.alertType = .statusFailure
  404. self.showAlert = true
  405. }
  406. }
  407. }
  408. private func fetchOverridePresetsFromStorage() async throws -> [OverridePreset] {
  409. let loopOverrides = ProfileManager.shared.loopOverrides
  410. return loopOverrides.map { override in
  411. let targetRange: ClosedRange<Double>?
  412. if override.targetRange.count >= 2 {
  413. let lowValue = override.targetRange[0].doubleValue(for: ProfileManager.shared.units)
  414. let highValue = override.targetRange[1].doubleValue(for: ProfileManager.shared.units)
  415. targetRange = lowValue ... highValue
  416. } else {
  417. targetRange = nil
  418. }
  419. return OverridePreset(
  420. name: override.name,
  421. symbol: override.symbol.isEmpty ? nil : override.symbol,
  422. targetRange: targetRange,
  423. insulinNeedsScaleFactor: override.insulinNeedsScaleFactor,
  424. duration: TimeInterval(override.duration ?? 0)
  425. )
  426. }
  427. }
  428. private func sendOverrideNotification(preset: OverridePreset, duration: TimeInterval?) async throws {
  429. return try await withCheckedThrowingContinuation { continuation in
  430. let apnsService = LoopAPNSService()
  431. apnsService.sendOverrideNotification(
  432. presetName: preset.name,
  433. duration: duration
  434. ) { success, errorMessage in
  435. if success {
  436. continuation.resume()
  437. } else {
  438. continuation.resume(throwing: NSError(domain: "OverrideError", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage ?? "Unknown error"]))
  439. }
  440. }
  441. }
  442. }
  443. private func sendCancelOverrideNotification() async throws {
  444. return try await withCheckedThrowingContinuation { continuation in
  445. let apnsService = LoopAPNSService()
  446. apnsService.sendCancelOverrideNotification { success, errorMessage in
  447. if success {
  448. continuation.resume()
  449. } else {
  450. continuation.resume(throwing: NSError(domain: "OverrideError", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage ?? "Unknown error"]))
  451. }
  452. }
  453. }
  454. }
  455. }
  456. // MARK: - Data Models
  457. struct OverridePreset {
  458. let name: String
  459. let symbol: String?
  460. let targetRange: ClosedRange<Double>?
  461. let insulinNeedsScaleFactor: Double?
  462. let duration: TimeInterval
  463. var durationDescription: String {
  464. if duration == 0 {
  465. return "Indefinite"
  466. } else {
  467. let hours = Int(duration) / 3600
  468. let minutes = Int(duration) % 3600 / 60
  469. if hours > 0 {
  470. return "\(hours)h \(minutes)m"
  471. } else {
  472. return "\(minutes)m"
  473. }
  474. }
  475. }
  476. }
  477. enum OverrideError: LocalizedError {
  478. case nightscoutNotConfigured
  479. case invalidResponse
  480. case serverError(Int)
  481. var errorDescription: String? {
  482. switch self {
  483. case .nightscoutNotConfigured:
  484. return "Nightscout URL and token not configured in settings"
  485. case .invalidResponse:
  486. return "Invalid response from server"
  487. case let .serverError(code):
  488. return "Server error: \(code)"
  489. }
  490. }
  491. }