RemoteSettingsView.swift 23 KB

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