RemoteSettingsView.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. // LoopFollow
  2. // RemoteSettingsView.swift
  3. import HealthKit
  4. import SwiftUI
  5. struct RemoteSettingsView: View {
  6. @ObservedObject var viewModel: RemoteSettingsViewModel
  7. @ObservedObject private var device = Storage.shared.device
  8. @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement
  9. @State private var showAlert: Bool = false
  10. @State private var alertType: AlertType? = nil
  11. @State private var alertMessage: String? = nil
  12. @State private var otpTimeRemaining: Int? = nil
  13. private let otpPeriod: TimeInterval = 30
  14. private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  15. enum AlertType {
  16. case validation
  17. case qrCodeError
  18. case urlTokenValidation
  19. case urlTokenUpdate
  20. }
  21. init(viewModel: RemoteSettingsViewModel) {
  22. self.viewModel = viewModel
  23. }
  24. var body: some View {
  25. Form {
  26. // MARK: - Remote Type Section (Custom Rows)
  27. Section {
  28. remoteTypeRow(
  29. type: .none,
  30. label: "None",
  31. isEnabled: true
  32. )
  33. remoteTypeRow(
  34. type: .loopAPNS,
  35. label: "Loop Remote Control",
  36. isEnabled: viewModel.isLoopDevice
  37. )
  38. remoteTypeRow(
  39. type: .trc,
  40. label: "Trio Remote Control",
  41. isEnabled: viewModel.isTrioDevice
  42. )
  43. remoteTypeRow(
  44. type: .nightscout,
  45. label: "Nightscout",
  46. isEnabled: viewModel.isTrioDevice
  47. )
  48. Text("Nightscout should be used for Trio 0.2.x.")
  49. .font(.footnote)
  50. .foregroundColor(.secondary)
  51. }
  52. // MARK: - QR Code Sharing Section
  53. Section {
  54. if viewModel.remoteType == .none {
  55. Button(action: {
  56. viewModel.isShowingQRCodeScanner = true
  57. }) {
  58. HStack {
  59. Image(systemName: "qrcode.viewfinder")
  60. Text("Import Remote Settings from QR Code")
  61. }
  62. }
  63. .buttonStyle(.borderedProminent)
  64. .frame(maxWidth: .infinity)
  65. .padding(.vertical, 10)
  66. } else {
  67. Button(action: {
  68. viewModel.isShowingQRCodeDisplay = true
  69. }) {
  70. HStack {
  71. Image(systemName: "qrcode")
  72. Text("Export Remote Settings as QR Code")
  73. }
  74. }
  75. .buttonStyle(.borderedProminent)
  76. .frame(maxWidth: .infinity)
  77. .padding(.vertical, 10)
  78. }
  79. }
  80. // MARK: - Meal Section (for TRC only)
  81. if viewModel.remoteType == .trc {
  82. Section(header: Text("Meal Settings")) {
  83. Toggle("Meal with Bolus", isOn: $viewModel.mealWithBolus)
  84. .toggleStyle(SwitchToggleStyle())
  85. Toggle("Meal with Fat/Protein", isOn: $viewModel.mealWithFatProtein)
  86. .toggleStyle(SwitchToggleStyle())
  87. }
  88. }
  89. // MARK: - Guardrails Section (shown for both TRC and Loop)
  90. if viewModel.remoteType == .trc || viewModel.remoteType == .loopAPNS {
  91. guardrailsSection
  92. }
  93. if !Storage.shared.bolusIncrementDetected.value {
  94. Section(header: Text("Bolus Increment")) {
  95. HStack {
  96. Text("Increment")
  97. Spacer()
  98. TextFieldWithToolBar(
  99. quantity: $bolusIncrement.value,
  100. maxLength: 5,
  101. unit: HKUnit.internationalUnit(),
  102. allowDecimalSeparator: true,
  103. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.001),
  104. maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 1),
  105. onValidationError: { message in
  106. handleValidationError(message)
  107. }
  108. )
  109. .frame(width: 100)
  110. Text("U")
  111. .foregroundColor(.secondary)
  112. }
  113. }
  114. }
  115. // MARK: - User Information Section
  116. if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS {
  117. Section(header: Text("User Information")) {
  118. HStack {
  119. Text("User")
  120. TextField("Enter User", text: $viewModel.user)
  121. .autocapitalization(.none)
  122. .disableAutocorrection(true)
  123. .multilineTextAlignment(.trailing)
  124. }
  125. }
  126. }
  127. // MARK: - Trio Remote Control Settings
  128. if viewModel.remoteType == .trc {
  129. Section(header: Text("Trio Remote Control Settings")) {
  130. HStack {
  131. Text("Shared Secret")
  132. TogglableSecureInput(
  133. placeholder: "Enter Shared Secret",
  134. text: $viewModel.sharedSecret,
  135. style: .singleLine
  136. )
  137. }
  138. HStack {
  139. Text("APNS Key ID")
  140. TogglableSecureInput(
  141. placeholder: "Enter APNS Key ID",
  142. text: $viewModel.keyId,
  143. style: .singleLine
  144. )
  145. }
  146. VStack(alignment: .leading) {
  147. Text("APNS Key")
  148. TogglableSecureInput(
  149. placeholder: "Paste APNS Key",
  150. text: $viewModel.apnsKey,
  151. style: .multiLine
  152. )
  153. .frame(minHeight: 110)
  154. }
  155. }
  156. // MARK: - Debug / Info
  157. Section(header: Text("Debug / Info")) {
  158. Text("Device Token: \(Storage.shared.deviceToken.value)")
  159. Text("APNS Environment: \(Storage.shared.productionEnvironment.value ? "Production" : "Development")")
  160. Text("Team ID: \(Storage.shared.teamId.value ?? "")")
  161. Text("Bundle ID: \(Storage.shared.bundleId.value)")
  162. if Storage.shared.bolusIncrementDetected.value {
  163. Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
  164. }
  165. }
  166. }
  167. // MARK: - Loop APNS Settings
  168. if viewModel.remoteType == .loopAPNS {
  169. Section(header: Text("Loop APNS Configuration")) {
  170. HStack {
  171. Text("Developer Team ID")
  172. TogglableSecureInput(
  173. placeholder: "Enter Team ID",
  174. text: $viewModel.loopDeveloperTeamId,
  175. style: .singleLine
  176. )
  177. }
  178. HStack {
  179. Text("APNS Key ID")
  180. TogglableSecureInput(
  181. placeholder: "Enter APNS Key ID",
  182. text: $viewModel.keyId,
  183. style: .singleLine
  184. )
  185. }
  186. VStack(alignment: .leading) {
  187. Text("APNS Key")
  188. TogglableSecureInput(
  189. placeholder: "Paste APNS Key",
  190. text: $viewModel.apnsKey,
  191. style: .multiLine
  192. )
  193. .frame(minHeight: 110)
  194. }
  195. HStack {
  196. Text("QR Code URL")
  197. TextField("Enter QR code URL or scan from Loop app", text: $viewModel.loopAPNSQrCodeURL)
  198. .textFieldStyle(RoundedBorderTextFieldStyle())
  199. .autocapitalization(.none)
  200. .disableAutocorrection(true)
  201. }
  202. Button(action: {
  203. viewModel.isShowingLoopAPNSScanner = true
  204. }) {
  205. HStack {
  206. Image(systemName: "qrcode.viewfinder")
  207. Text("Scan QR Code from Loop App")
  208. }
  209. }
  210. .buttonStyle(.borderedProminent)
  211. .frame(maxWidth: .infinity)
  212. .padding(.vertical, 10)
  213. HStack {
  214. Text("Environment")
  215. Spacer()
  216. Toggle("Production", isOn: $viewModel.productionEnvironment)
  217. .toggleStyle(SwitchToggleStyle())
  218. }
  219. Text("Production is used for browser builders and should be switched off for Xcode builders")
  220. .font(.caption)
  221. .foregroundColor(.secondary)
  222. }
  223. if let errorMessage = viewModel.loopAPNSErrorMessage, !errorMessage.isEmpty {
  224. Section {
  225. Text(errorMessage)
  226. .foregroundColor(.red)
  227. .font(.caption)
  228. }
  229. }
  230. Section(header: Text("Debug / Info")) {
  231. Text("Device Token: \(Storage.shared.deviceToken.value)")
  232. Text("Bundle ID: \(Storage.shared.bundleId.value)")
  233. if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) {
  234. HStack {
  235. Text("Current TOTP Code:")
  236. Text(otpCode)
  237. .font(.system(.body, design: .monospaced))
  238. .foregroundColor(.green)
  239. .padding(.vertical, 2)
  240. .padding(.horizontal, 6)
  241. .background(Color.green.opacity(0.1))
  242. .cornerRadius(4)
  243. Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")")
  244. .font(.caption)
  245. .foregroundColor(.secondary)
  246. }
  247. } else {
  248. Text("TOTP Code: Invalid QR code URL")
  249. .foregroundColor(.red)
  250. }
  251. if Storage.shared.bolusIncrementDetected.value {
  252. Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
  253. }
  254. }
  255. if viewModel.areTeamIdsDifferent {
  256. Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) {
  257. HStack {
  258. Text("Return APNS Key ID")
  259. TogglableSecureInput(
  260. placeholder: "Enter Key ID for LoopFollow",
  261. text: $viewModel.returnKeyId,
  262. style: .singleLine
  263. )
  264. }
  265. VStack(alignment: .leading) {
  266. Text("Return APNS Key")
  267. TogglableSecureInput(
  268. placeholder: "Paste APNS Key for LoopFollow",
  269. text: $viewModel.returnApnsKey,
  270. style: .multiLine
  271. )
  272. .frame(minHeight: 110)
  273. }
  274. }
  275. }
  276. }
  277. }
  278. .alert(isPresented: $showAlert) {
  279. switch alertType {
  280. case .validation:
  281. return Alert(
  282. title: Text("Validation Error"),
  283. message: Text(alertMessage ?? "Invalid input."),
  284. dismissButton: .default(Text("OK"))
  285. )
  286. case .qrCodeError:
  287. return Alert(
  288. title: Text("QR Code Error"),
  289. message: Text(alertMessage ?? "An error occurred while processing the QR code."),
  290. dismissButton: .default(Text("OK"))
  291. )
  292. case .urlTokenValidation:
  293. return Alert(
  294. title: Text("URL/Token Validation"),
  295. message: Text(viewModel.validationMessage),
  296. dismissButton: .default(Text("OK")) {
  297. viewModel.showURLTokenValidation = false
  298. }
  299. )
  300. case .urlTokenUpdate:
  301. return Alert(
  302. title: Text("URL/Token Update"),
  303. message: Text(viewModel.validationMessage),
  304. dismissButton: .default(Text("OK")) {
  305. viewModel.showURLTokenValidation = false
  306. }
  307. )
  308. case .none:
  309. return Alert(title: Text("Unknown Alert"))
  310. }
  311. }
  312. .sheet(isPresented: $viewModel.isShowingLoopAPNSScanner) {
  313. SimpleQRCodeScannerView { result in
  314. viewModel.handleLoopAPNSQRCodeScanResult(result)
  315. }
  316. }
  317. .sheet(isPresented: $viewModel.isShowingQRCodeScanner) {
  318. SimpleQRCodeScannerView { result in
  319. viewModel.handleRemoteCommandQRCodeScanResult(result)
  320. }
  321. }
  322. .sheet(isPresented: $viewModel.isShowingQRCodeDisplay) {
  323. NavigationView {
  324. VStack {
  325. if let qrCodeString = viewModel.generateQRCodeForCurrentSettings() {
  326. QRCodeDisplayView(qrCodeString: qrCodeString)
  327. .padding()
  328. } else {
  329. Text("Failed to generate QR code")
  330. .foregroundColor(.red)
  331. .padding()
  332. }
  333. }
  334. .navigationTitle("Share Remote Settings")
  335. .navigationBarTitleDisplayMode(.inline)
  336. .navigationBarItems(trailing: Button("Done") {
  337. viewModel.isShowingQRCodeDisplay = false
  338. })
  339. }
  340. }
  341. .sheet(isPresented: $viewModel.showURLTokenValidation) {
  342. NavigationView {
  343. URLTokenValidationView(
  344. settings: viewModel.pendingSettings!,
  345. shouldPromptForURL: viewModel.shouldPromptForURL,
  346. shouldPromptForToken: viewModel.shouldPromptForToken,
  347. message: viewModel.validationMessage,
  348. onConfirm: { confirmedSettings in
  349. confirmedSettings.applyToStorage()
  350. viewModel.updateViewModelFromStorage()
  351. viewModel.showURLTokenValidation = false
  352. viewModel.pendingSettings = nil
  353. LogManager.shared.log(category: .remote, message: "Remote command settings imported from QR code with URL/token updates")
  354. },
  355. onCancel: {
  356. viewModel.showURLTokenValidation = false
  357. viewModel.pendingSettings = nil
  358. }
  359. )
  360. }
  361. }
  362. .onAppear {
  363. // Reset timer state so it shows '-' until first tick
  364. otpTimeRemaining = nil
  365. // Update view model from storage to ensure UI is current
  366. viewModel.updateViewModelFromStorage()
  367. }
  368. .onReceive(otpTimer) { _ in
  369. let now = Date().timeIntervalSince1970
  370. otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
  371. }
  372. .onReceive(viewModel.$qrCodeErrorMessage) { errorMessage in
  373. if let errorMessage = errorMessage, !errorMessage.isEmpty {
  374. handleQRCodeError(errorMessage)
  375. // Clear the error message after showing the alert
  376. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  377. viewModel.qrCodeErrorMessage = nil
  378. }
  379. }
  380. }
  381. .onReceive(viewModel.$showURLTokenValidation) { showValidation in
  382. if showValidation {
  383. // The sheet will be shown automatically due to the binding
  384. }
  385. }
  386. .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
  387. .navigationTitle("Remote Settings")
  388. .navigationBarTitleDisplayMode(.inline)
  389. }
  390. // MARK: - Custom Row for Remote Type Selection
  391. private func remoteTypeRow(type: RemoteType, label: String, isEnabled: Bool) -> some View {
  392. Button(action: {
  393. if isEnabled {
  394. viewModel.remoteType = type
  395. }
  396. }) {
  397. HStack {
  398. Text(label)
  399. Spacer()
  400. if viewModel.remoteType == type {
  401. Image(systemName: "checkmark")
  402. .foregroundColor(.accentColor)
  403. }
  404. }
  405. }
  406. // If isEnabled is false, user can see the row but not tap it.
  407. .disabled(!isEnabled)
  408. .foregroundColor(isEnabled ? .primary : .gray)
  409. }
  410. // MARK: - Validation Error Handler
  411. private func handleValidationError(_ message: String) {
  412. alertMessage = message
  413. alertType = .validation
  414. showAlert = true
  415. }
  416. // MARK: - QR Code Error Handler
  417. private func handleQRCodeError(_ message: String) {
  418. alertMessage = message
  419. alertType = .qrCodeError
  420. showAlert = true
  421. }
  422. private var guardrailsSection: some View {
  423. Section(header: Text("Guardrails")) {
  424. HStack {
  425. Text("Max Bolus")
  426. Spacer()
  427. TextFieldWithToolBar(
  428. quantity: $viewModel.maxBolus,
  429. maxLength: 5,
  430. unit: HKUnit.internationalUnit(),
  431. allowDecimalSeparator: true,
  432. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
  433. maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 10),
  434. onValidationError: { message in
  435. handleValidationError(message)
  436. }
  437. )
  438. .frame(width: 100)
  439. Text("U")
  440. .foregroundColor(.secondary)
  441. }
  442. HStack {
  443. Text("Max Carbs")
  444. Spacer()
  445. TextFieldWithToolBar(
  446. quantity: $viewModel.maxCarbs,
  447. maxLength: 4,
  448. unit: HKUnit.gram(),
  449. allowDecimalSeparator: true,
  450. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  451. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  452. onValidationError: { message in
  453. handleValidationError(message)
  454. }
  455. )
  456. .frame(width: 100)
  457. Text("g")
  458. .foregroundColor(.secondary)
  459. }
  460. if device.value == "Trio" {
  461. HStack {
  462. Text("Max Protein")
  463. Spacer()
  464. TextFieldWithToolBar(
  465. quantity: $viewModel.maxProtein,
  466. maxLength: 4,
  467. unit: HKUnit.gram(),
  468. allowDecimalSeparator: true,
  469. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  470. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  471. onValidationError: { message in
  472. handleValidationError(message)
  473. }
  474. )
  475. .frame(width: 100)
  476. Text("g")
  477. .foregroundColor(.secondary)
  478. }
  479. HStack {
  480. Text("Max Fat")
  481. Spacer()
  482. TextFieldWithToolBar(
  483. quantity: $viewModel.maxFat,
  484. maxLength: 4,
  485. unit: HKUnit.gram(),
  486. allowDecimalSeparator: true,
  487. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  488. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  489. onValidationError: { message in
  490. handleValidationError(message)
  491. }
  492. )
  493. .frame(width: 100)
  494. Text("g")
  495. .foregroundColor(.secondary)
  496. }
  497. }
  498. }
  499. }
  500. }