RemoteSettingsView.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. // LoopFollow
  2. // RemoteSettingsView.swift
  3. import AVFoundation
  4. import HealthKit
  5. import SwiftUI
  6. import UIKit
  7. struct RemoteSettingsView: View {
  8. @ObservedObject var viewModel: RemoteSettingsViewModel
  9. @ObservedObject private var device = Storage.shared.device
  10. @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement
  11. @State private var showAlert: Bool = false
  12. @State private var alertType: AlertType? = nil
  13. @State private var alertMessage: String? = nil
  14. @State private var otpTimeRemaining: Int? = nil
  15. private let otpPeriod: TimeInterval = 30
  16. private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  17. enum AlertType {
  18. case validation
  19. case urlTokenValidation
  20. case urlTokenUpdate
  21. }
  22. init(viewModel: RemoteSettingsViewModel) {
  23. self.viewModel = viewModel
  24. }
  25. private let diagnosticsAnchorID = "remoteDiagnostics"
  26. var body: some View {
  27. ScrollViewReader { proxy in
  28. formContent
  29. .onChange(of: viewModel.diagnostics.status) { _ in
  30. withAnimation {
  31. proxy.scrollTo(diagnosticsAnchorID, anchor: .top)
  32. }
  33. }
  34. }
  35. }
  36. private var formContent: some View {
  37. Form {
  38. // MARK: - Remote Type Section (Custom Rows)
  39. Section {
  40. remoteTypeRow(
  41. type: .none,
  42. label: "None",
  43. isEnabled: true
  44. )
  45. remoteTypeRow(
  46. type: .loopAPNS,
  47. label: "Loop Remote Control",
  48. isEnabled: viewModel.isLoopDevice
  49. )
  50. remoteTypeRow(
  51. type: .trc,
  52. label: "Trio Remote Control",
  53. isEnabled: viewModel.isTrioDevice
  54. )
  55. }
  56. // MARK: - Import/Export Settings Section
  57. Section {
  58. NavigationLink(destination: ImportExportSettingsView()) {
  59. HStack {
  60. Image(systemName: "square.and.arrow.down")
  61. .foregroundColor(.blue)
  62. Text("Import/Export Settings")
  63. }
  64. }
  65. }
  66. // MARK: - Meal Section (for TRC only)
  67. if viewModel.remoteType == .trc {
  68. Section(header: Text("Meal Settings")) {
  69. Toggle("Meal with Bolus", isOn: $viewModel.mealWithBolus)
  70. .toggleStyle(SwitchToggleStyle())
  71. Toggle("Meal with Fat/Protein", isOn: $viewModel.mealWithFatProtein)
  72. .toggleStyle(SwitchToggleStyle())
  73. }
  74. }
  75. // MARK: - Guardrails Section (shown for both TRC and Loop)
  76. if viewModel.remoteType == .trc || viewModel.remoteType == .loopAPNS {
  77. guardrailsSection
  78. }
  79. if !Storage.shared.bolusIncrementDetected.value {
  80. Section(header: Text("Bolus Increment")) {
  81. HStack {
  82. Text("Increment")
  83. Spacer()
  84. TextFieldWithToolBar(
  85. quantity: $bolusIncrement.value,
  86. maxLength: 5,
  87. unit: HKUnit.internationalUnit(),
  88. allowDecimalSeparator: true,
  89. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.001),
  90. maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 1),
  91. onValidationError: { message in
  92. handleValidationError(message)
  93. }
  94. )
  95. .frame(width: 100)
  96. Text("U")
  97. .foregroundColor(.secondary)
  98. }
  99. }
  100. }
  101. // MARK: - User Information Section
  102. if viewModel.remoteType == .trc {
  103. Section(header: Text("User Information")) {
  104. HStack {
  105. Text("User")
  106. TextField("Enter User", text: $viewModel.user)
  107. .autocapitalization(.none)
  108. .disableAutocorrection(true)
  109. .multilineTextAlignment(.trailing)
  110. }
  111. }
  112. }
  113. // MARK: - Trio Remote Control Settings
  114. if viewModel.remoteType == .trc {
  115. Section(header: Text("Trio Remote Control Settings")) {
  116. HStack {
  117. Text("Shared Secret")
  118. TogglableSecureInput(
  119. placeholder: "Enter Shared Secret",
  120. text: $viewModel.sharedSecret,
  121. style: .singleLine
  122. )
  123. }
  124. if viewModel.areTeamIdsDifferent {
  125. HStack {
  126. Text("APNS Key ID")
  127. TogglableSecureInput(
  128. placeholder: "Enter APNS Key ID",
  129. text: $viewModel.remoteKeyId,
  130. style: .singleLine
  131. )
  132. }
  133. VStack(alignment: .leading) {
  134. Text("APNS Key")
  135. TogglableSecureInput(
  136. placeholder: "Paste APNS Key",
  137. text: $viewModel.remoteApnsKey,
  138. style: .multiLine
  139. )
  140. .frame(minHeight: 110)
  141. }
  142. }
  143. }
  144. // MARK: - Debug / Info
  145. Section(header: Text("Debug / Info")) {
  146. Text("Device Token: \(Storage.shared.deviceToken.value)")
  147. Text("APNS Environment: \(Storage.shared.productionEnvironment.value ? "Production" : "Development")")
  148. Text("Team ID: \(Storage.shared.teamId.value ?? "")")
  149. Text("Bundle ID: \(Storage.shared.bundleId.value)")
  150. if Storage.shared.bolusIncrementDetected.value {
  151. Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
  152. }
  153. diagnosticsRows
  154. .id(diagnosticsAnchorID)
  155. }
  156. }
  157. // MARK: - Loop APNS Settings
  158. if viewModel.remoteType == .loopAPNS {
  159. Section(header: Text("Loop APNS Configuration")) {
  160. HStack {
  161. Text("Developer Team ID")
  162. TogglableSecureInput(
  163. placeholder: "Enter Team ID",
  164. text: $viewModel.loopDeveloperTeamId,
  165. style: .singleLine
  166. )
  167. }
  168. if viewModel.areTeamIdsDifferent {
  169. HStack {
  170. Text("APNS Key ID")
  171. TogglableSecureInput(
  172. placeholder: "Enter APNS Key ID",
  173. text: $viewModel.remoteKeyId,
  174. style: .singleLine
  175. )
  176. }
  177. VStack(alignment: .leading) {
  178. Text("APNS Key")
  179. TogglableSecureInput(
  180. placeholder: "Paste APNS Key",
  181. text: $viewModel.remoteApnsKey,
  182. style: .multiLine
  183. )
  184. .frame(minHeight: 110)
  185. }
  186. }
  187. HStack {
  188. Text("QR Code URL")
  189. TextField("Enter QR code URL or scan from Loop app", text: $viewModel.loopAPNSQrCodeURL)
  190. .textFieldStyle(RoundedBorderTextFieldStyle())
  191. .autocapitalization(.none)
  192. .disableAutocorrection(true)
  193. }
  194. Button(action: {
  195. viewModel.isShowingLoopAPNSScanner = true
  196. }) {
  197. HStack {
  198. Image(systemName: "qrcode.viewfinder")
  199. Text("Scan QR Code from Loop App")
  200. }
  201. }
  202. .buttonStyle(.borderedProminent)
  203. .frame(maxWidth: .infinity)
  204. .padding(.vertical, 10)
  205. HStack {
  206. Text("Environment")
  207. Spacer()
  208. Toggle("Production", isOn: $viewModel.productionEnvironment)
  209. .toggleStyle(SwitchToggleStyle())
  210. }
  211. Text("Production is used for browser builders and should be switched off for Xcode builders")
  212. .font(.caption)
  213. .foregroundColor(.secondary)
  214. }
  215. if let errorMessage = viewModel.loopAPNSErrorMessage, !errorMessage.isEmpty {
  216. Section {
  217. Text(errorMessage)
  218. .foregroundColor(.red)
  219. .font(.caption)
  220. }
  221. }
  222. Section(header: Text("Debug / Info")) {
  223. Text("Device Token: \(Storage.shared.deviceToken.value)")
  224. Text("Bundle ID: \(Storage.shared.bundleId.value)")
  225. if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) {
  226. HStack {
  227. Text("Current TOTP Code:")
  228. Text(otpCode)
  229. .font(.system(.body, design: .monospaced))
  230. .foregroundColor(.green)
  231. .padding(.vertical, 2)
  232. .padding(.horizontal, 6)
  233. .background(Color.green.opacity(0.1))
  234. .cornerRadius(4)
  235. Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")")
  236. .font(.caption)
  237. .foregroundColor(.secondary)
  238. }
  239. } else {
  240. Text("TOTP Code: Invalid QR code URL")
  241. .foregroundColor(.red)
  242. }
  243. if Storage.shared.bolusIncrementDetected.value {
  244. Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
  245. }
  246. diagnosticsRows
  247. .id(diagnosticsAnchorID)
  248. }
  249. }
  250. }
  251. .alert(isPresented: $showAlert) {
  252. switch alertType {
  253. case .validation:
  254. return Alert(
  255. title: Text("Validation Error"),
  256. message: Text(alertMessage ?? "Invalid input."),
  257. dismissButton: .default(Text("OK"))
  258. )
  259. case .urlTokenValidation:
  260. return Alert(
  261. title: Text("URL/Token Validation"),
  262. message: Text(viewModel.validationMessage),
  263. dismissButton: .default(Text("OK")) {
  264. viewModel.showURLTokenValidation = false
  265. }
  266. )
  267. case .urlTokenUpdate:
  268. return Alert(
  269. title: Text("URL/Token Update"),
  270. message: Text(viewModel.validationMessage),
  271. dismissButton: .default(Text("OK")) {
  272. viewModel.showURLTokenValidation = false
  273. }
  274. )
  275. case .none:
  276. return Alert(title: Text("Unknown Alert"))
  277. }
  278. }
  279. .sheet(isPresented: $viewModel.isShowingLoopAPNSScanner) {
  280. SimpleQRCodeScannerView { result in
  281. viewModel.handleLoopAPNSQRCodeScanResult(result)
  282. }
  283. }
  284. .sheet(isPresented: $viewModel.showURLTokenValidation) {
  285. NavigationView {
  286. URLTokenValidationView(
  287. settings: viewModel.pendingSettings!,
  288. shouldPromptForURL: viewModel.shouldPromptForURL,
  289. shouldPromptForToken: viewModel.shouldPromptForToken,
  290. message: viewModel.validationMessage,
  291. onConfirm: { confirmedSettings in
  292. confirmedSettings.applyToStorage()
  293. viewModel.updateViewModelFromStorage()
  294. viewModel.showURLTokenValidation = false
  295. viewModel.pendingSettings = nil
  296. LogManager.shared.log(category: .remote, message: "Remote command settings imported from QR code with URL/token updates")
  297. },
  298. onCancel: {
  299. viewModel.showURLTokenValidation = false
  300. viewModel.pendingSettings = nil
  301. }
  302. )
  303. }
  304. }
  305. .onAppear {
  306. // Reset timer state so it shows '-' until first tick
  307. otpTimeRemaining = nil
  308. // Update view model from storage to ensure UI is current
  309. viewModel.updateViewModelFromStorage()
  310. }
  311. .onReceive(otpTimer) { _ in
  312. let now = Date().timeIntervalSince1970
  313. otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
  314. }
  315. .onReceive(viewModel.$showURLTokenValidation) { showValidation in
  316. if showValidation {
  317. // The sheet will be shown automatically due to the binding
  318. }
  319. }
  320. .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
  321. .navigationTitle("Remote Settings")
  322. .navigationBarTitleDisplayMode(.inline)
  323. }
  324. // MARK: - Custom Row for Remote Type Selection
  325. private func remoteTypeRow(type: RemoteType, label: String, isEnabled: Bool) -> some View {
  326. Button(action: {
  327. if isEnabled {
  328. viewModel.remoteType = type
  329. }
  330. }) {
  331. HStack {
  332. Text(label)
  333. Spacer()
  334. if viewModel.remoteType == type {
  335. Image(systemName: "checkmark")
  336. .foregroundColor(.accentColor)
  337. }
  338. }
  339. }
  340. // If isEnabled is false, user can see the row but not tap it.
  341. .disabled(!isEnabled)
  342. .foregroundColor(isEnabled ? .primary : .gray)
  343. }
  344. // MARK: - Validation Error Handler
  345. private func handleValidationError(_ message: String) {
  346. alertMessage = message
  347. alertType = .validation
  348. showAlert = true
  349. }
  350. private var guardrailsSection: some View {
  351. Section(header: Text("Guardrails")) {
  352. HStack {
  353. Text("Max Bolus")
  354. Spacer()
  355. TextFieldWithToolBar(
  356. quantity: $viewModel.maxBolus,
  357. maxLength: 5,
  358. unit: HKUnit.internationalUnit(),
  359. allowDecimalSeparator: true,
  360. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
  361. maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 10),
  362. onValidationError: { message in
  363. handleValidationError(message)
  364. }
  365. )
  366. .frame(width: 100)
  367. Text("U")
  368. .foregroundColor(.secondary)
  369. }
  370. HStack {
  371. Text("Max Carbs")
  372. Spacer()
  373. TextFieldWithToolBar(
  374. quantity: $viewModel.maxCarbs,
  375. maxLength: 4,
  376. unit: HKUnit.gram(),
  377. allowDecimalSeparator: true,
  378. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  379. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  380. onValidationError: { message in
  381. handleValidationError(message)
  382. }
  383. )
  384. .frame(width: 100)
  385. Text("g")
  386. .foregroundColor(.secondary)
  387. }
  388. if device.value == "Trio" {
  389. HStack {
  390. Text("Max Fat")
  391. Spacer()
  392. TextFieldWithToolBar(
  393. quantity: $viewModel.maxFat,
  394. maxLength: 4,
  395. unit: HKUnit.gram(),
  396. allowDecimalSeparator: true,
  397. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  398. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  399. onValidationError: { message in
  400. handleValidationError(message)
  401. }
  402. )
  403. .frame(width: 100)
  404. Text("g")
  405. .foregroundColor(.secondary)
  406. }
  407. HStack {
  408. Text("Max Protein")
  409. Spacer()
  410. TextFieldWithToolBar(
  411. quantity: $viewModel.maxProtein,
  412. maxLength: 4,
  413. unit: HKUnit.gram(),
  414. allowDecimalSeparator: true,
  415. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  416. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  417. onValidationError: { message in
  418. handleValidationError(message)
  419. }
  420. )
  421. .frame(width: 100)
  422. Text("g")
  423. .foregroundColor(.secondary)
  424. }
  425. }
  426. }
  427. }
  428. // MARK: - Diagnostics
  429. @ViewBuilder
  430. private var diagnosticsRows: some View {
  431. switch viewModel.diagnostics.status {
  432. case .running:
  433. HStack {
  434. ProgressView()
  435. Text("Checking Nightscout profile history…")
  436. .foregroundColor(.secondary)
  437. }
  438. case .unknown:
  439. Button(action: { viewModel.runDiagnostics() }) {
  440. HStack {
  441. Image(systemName: "stethoscope")
  442. Text("Run diagnostics")
  443. }
  444. }
  445. case let .failed(message):
  446. Button(action: { viewModel.runDiagnostics() }) {
  447. HStack {
  448. Image(systemName: "stethoscope")
  449. Text("Run diagnostics again")
  450. }
  451. }
  452. Text("Diagnostics unavailable: \(message)")
  453. .font(.footnote)
  454. .foregroundColor(.secondary)
  455. case .ok:
  456. Button(action: { viewModel.runDiagnostics() }) {
  457. HStack {
  458. Image(systemName: "stethoscope")
  459. Text("Run diagnostics again")
  460. }
  461. }
  462. if let mismatch = viewModel.diagnostics.bundleMismatch {
  463. diagnosticWarning(
  464. title: "Profile uploaded by a different app",
  465. detail: "The current Nightscout profile was uploaded by \(mismatch.observedBundleId), but you're configured for \(mismatch.expectedDevice). When Loop and Trio share a Nightscout, they overwrite each other's profile."
  466. )
  467. }
  468. if let bouncing = viewModel.diagnostics.bouncingTokens {
  469. bouncingTokensWarning(bouncing)
  470. }
  471. if let future = viewModel.diagnostics.futureStartDate {
  472. diagnosticWarning(
  473. title: "Future-dated profile record found",
  474. detail: "A profile record has startDate \(dateTimeUtils.formattedDate(from: future.startDate)). LoopFollow ignores future-dated records, but it will still appear as the current profile in your Nightscout dashboard. Consider deleting it — it usually means a phone with the wrong system clock is uploading."
  475. )
  476. }
  477. if !viewModel.diagnostics.hasAnyWarning {
  478. HStack {
  479. Image(systemName: "checkmark.seal")
  480. .foregroundColor(.green)
  481. Text("No issues detected")
  482. .foregroundColor(.secondary)
  483. }
  484. }
  485. }
  486. }
  487. private func diagnosticWarning(title: String, detail: String) -> some View {
  488. VStack(alignment: .leading, spacing: 4) {
  489. HStack {
  490. Image(systemName: "exclamationmark.triangle")
  491. .foregroundColor(.orange)
  492. Text(title)
  493. .fontWeight(.semibold)
  494. .foregroundColor(.orange)
  495. }
  496. Text(detail)
  497. .font(.footnote)
  498. .foregroundColor(.secondary)
  499. }
  500. .padding(.vertical, 4)
  501. }
  502. @ViewBuilder
  503. private func bouncingTokensWarning(_ bouncing: RemoteDiagnostics.BouncingTokens) -> some View {
  504. VStack(alignment: .leading, spacing: 4) {
  505. HStack {
  506. Image(systemName: "exclamationmark.triangle")
  507. .foregroundColor(.orange)
  508. Text("Multiple devices uploading profiles")
  509. .fontWeight(.semibold)
  510. .foregroundColor(.orange)
  511. }
  512. Text("Device tokens are alternating in recent profile uploads (\(bouncing.distinctCount) tokens involved across \(bouncing.recordsScanned) records). This usually means more than one app installation is uploading to the same Nightscout. Remove the app from spare or unused phones.")
  513. .font(.footnote)
  514. .foregroundColor(.secondary)
  515. if !bouncing.shifts.isEmpty {
  516. VStack(alignment: .leading, spacing: 2) {
  517. ForEach(Array(bouncing.shifts.enumerated()), id: \.offset) { _, shift in
  518. Text("\(shiftTimestampFormatter.string(from: shift.when)) \(abbreviateToken(shift.fromToken)) → \(abbreviateToken(shift.toToken))")
  519. .font(.system(.caption2, design: .monospaced))
  520. .foregroundColor(.secondary)
  521. }
  522. }
  523. .padding(.top, 2)
  524. }
  525. }
  526. .padding(.vertical, 4)
  527. }
  528. private var shiftTimestampFormatter: DateFormatter {
  529. let f = DateFormatter()
  530. f.dateFormat = "yyyy-MM-dd HH:mm"
  531. return f
  532. }
  533. private func abbreviateToken(_ token: String) -> String {
  534. guard token.count > 16 else { return token }
  535. return "\(token.prefix(7))…\(token.suffix(6))"
  536. }
  537. }