OverridePresetsView.swift 20 KB

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