RemoteSettingsView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. // LoopFollow
  2. // RemoteSettingsView.swift
  3. // Created by Jonas Björkert.
  4. import HealthKit
  5. import SwiftUI
  6. struct RemoteSettingsView: View {
  7. @ObservedObject var viewModel: RemoteSettingsViewModel
  8. @ObservedObject private var device = Storage.shared.device
  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. }
  18. init(viewModel: RemoteSettingsViewModel) {
  19. self.viewModel = viewModel
  20. }
  21. var body: some View {
  22. Form {
  23. // MARK: - Remote Type Section (Custom Rows)
  24. Section {
  25. remoteTypeRow(
  26. type: .none,
  27. label: "None",
  28. isEnabled: true
  29. )
  30. remoteTypeRow(
  31. type: .loopAPNS,
  32. label: "Loop Remote Control",
  33. isEnabled: viewModel.isLoopDevice
  34. )
  35. remoteTypeRow(
  36. type: .trc,
  37. label: "Trio Remote Control",
  38. isEnabled: viewModel.isTrioDevice
  39. )
  40. remoteTypeRow(
  41. type: .nightscout,
  42. label: "Nightscout",
  43. isEnabled: viewModel.isTrioDevice
  44. )
  45. Text("Nightscout should be used for Trio 0.2.x.")
  46. .font(.footnote)
  47. .foregroundColor(.secondary)
  48. }
  49. // MARK: - Meal Section (for TRC only)
  50. if viewModel.remoteType == .trc {
  51. Section(header: Text("Meal Settings")) {
  52. Toggle("Meal with Bolus", isOn: $viewModel.mealWithBolus)
  53. .toggleStyle(SwitchToggleStyle())
  54. Toggle("Meal with Fat/Protein", isOn: $viewModel.mealWithFatProtein)
  55. .toggleStyle(SwitchToggleStyle())
  56. }
  57. }
  58. // MARK: - Guardrails Section (shown for both TRC and Loop)
  59. if viewModel.remoteType == .trc || viewModel.remoteType == .loopAPNS {
  60. guardrailsSection
  61. }
  62. // MARK: - User Information Section
  63. if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS {
  64. Section(header: Text("User Information")) {
  65. HStack {
  66. Text("User")
  67. TextField("Enter User", text: $viewModel.user)
  68. .autocapitalization(.none)
  69. .disableAutocorrection(true)
  70. .multilineTextAlignment(.trailing)
  71. }
  72. }
  73. }
  74. // MARK: - Trio Remote Control Settings
  75. if viewModel.remoteType == .trc {
  76. Section(header: Text("Trio Remote Control Settings")) {
  77. HStack {
  78. Text("Shared Secret")
  79. TogglableSecureInput(
  80. placeholder: "Enter Shared Secret",
  81. text: $viewModel.sharedSecret,
  82. style: .singleLine
  83. )
  84. }
  85. HStack {
  86. Text("APNS Key ID")
  87. TogglableSecureInput(
  88. placeholder: "Enter APNS Key ID",
  89. text: $viewModel.keyId,
  90. style: .singleLine
  91. )
  92. }
  93. VStack(alignment: .leading) {
  94. Text("APNS Key")
  95. TogglableSecureInput(
  96. placeholder: "Paste APNS Key",
  97. text: $viewModel.apnsKey,
  98. style: .multiLine
  99. )
  100. .frame(minHeight: 110)
  101. }
  102. }
  103. // MARK: - Debug / Info
  104. Section(header: Text("Debug / Info")) {
  105. Text("Device Token: \(Storage.shared.deviceToken.value)")
  106. Text("Production Env.: \(Storage.shared.productionEnvironment.value ? "True" : "False")")
  107. Text("Team ID: \(Storage.shared.teamId.value ?? "")")
  108. Text("Bundle ID: \(Storage.shared.bundleId.value)")
  109. }
  110. }
  111. // MARK: - Loop APNS Settings
  112. if viewModel.remoteType == .loopAPNS {
  113. Section(header: Text("Loop APNS Configuration")) {
  114. HStack {
  115. Text("Developer Team ID")
  116. TogglableSecureInput(
  117. placeholder: "Enter Team ID",
  118. text: $viewModel.loopDeveloperTeamId,
  119. style: .singleLine
  120. )
  121. }
  122. HStack {
  123. Text("APNS Key ID")
  124. TogglableSecureInput(
  125. placeholder: "Enter APNS Key ID",
  126. text: $viewModel.keyId,
  127. style: .singleLine
  128. )
  129. }
  130. VStack(alignment: .leading) {
  131. Text("APNS Key")
  132. TogglableSecureInput(
  133. placeholder: "Paste APNS Key",
  134. text: $viewModel.apnsKey,
  135. style: .multiLine
  136. )
  137. .frame(minHeight: 110)
  138. }
  139. HStack {
  140. Text("QR Code URL")
  141. TextField("Enter QR code URL or scan from Loop app", text: $viewModel.loopAPNSQrCodeURL)
  142. .textFieldStyle(RoundedBorderTextFieldStyle())
  143. .autocapitalization(.none)
  144. .disableAutocorrection(true)
  145. }
  146. Button(action: {
  147. viewModel.isShowingLoopAPNSScanner = true
  148. }) {
  149. HStack {
  150. Image(systemName: "qrcode.viewfinder")
  151. Text("Scan QR Code from Loop App")
  152. }
  153. }
  154. .buttonStyle(.borderedProminent)
  155. .frame(maxWidth: .infinity)
  156. .padding(.vertical, 10)
  157. HStack {
  158. Text("Environment")
  159. Spacer()
  160. Toggle("Production", isOn: $viewModel.productionEnvironment)
  161. .toggleStyle(SwitchToggleStyle())
  162. }
  163. Text("Production is used for browser builders and should be switched off for Xcode builders")
  164. .font(.caption)
  165. .foregroundColor(.secondary)
  166. }
  167. if let errorMessage = viewModel.loopAPNSErrorMessage, !errorMessage.isEmpty {
  168. Section {
  169. Text(errorMessage)
  170. .foregroundColor(.red)
  171. .font(.caption)
  172. }
  173. }
  174. Section(header: Text("Debug / Info")) {
  175. Text("Device Token: \(Storage.shared.deviceToken.value)")
  176. Text("Bundle ID: \(Storage.shared.bundleId.value)")
  177. if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) {
  178. HStack {
  179. Text("Current TOTP Code:")
  180. Text(otpCode)
  181. .font(.system(.body, design: .monospaced))
  182. .foregroundColor(.green)
  183. .padding(.vertical, 2)
  184. .padding(.horizontal, 6)
  185. .background(Color.green.opacity(0.1))
  186. .cornerRadius(4)
  187. Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")")
  188. .font(.caption)
  189. .foregroundColor(.secondary)
  190. }
  191. } else {
  192. Text("TOTP Code: Invalid QR code URL")
  193. .foregroundColor(.red)
  194. }
  195. }
  196. if viewModel.areTeamIdsDifferent {
  197. 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)) {
  198. HStack {
  199. Text("Return APNS Key ID")
  200. TogglableSecureInput(
  201. placeholder: "Enter Key ID for LoopFollow",
  202. text: $viewModel.returnKeyId,
  203. style: .singleLine
  204. )
  205. }
  206. VStack(alignment: .leading) {
  207. Text("Return APNS Key")
  208. TogglableSecureInput(
  209. placeholder: "Paste APNS Key for LoopFollow",
  210. text: $viewModel.returnApnsKey,
  211. style: .multiLine
  212. )
  213. .frame(minHeight: 110)
  214. }
  215. }
  216. }
  217. }
  218. }
  219. .alert(isPresented: $showAlert) {
  220. switch alertType {
  221. case .validation:
  222. return Alert(
  223. title: Text("Validation Error"),
  224. message: Text(alertMessage ?? "Invalid input."),
  225. dismissButton: .default(Text("OK"))
  226. )
  227. case .none:
  228. return Alert(title: Text("Unknown Alert"))
  229. }
  230. }
  231. .sheet(isPresented: $viewModel.isShowingLoopAPNSScanner) {
  232. SimpleQRCodeScannerView { result in
  233. viewModel.handleLoopAPNSQRCodeScanResult(result)
  234. }
  235. }
  236. .onAppear {
  237. // Reset timer state so it shows '-' until first tick
  238. otpTimeRemaining = nil
  239. }
  240. .onReceive(otpTimer) { _ in
  241. let now = Date().timeIntervalSince1970
  242. otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod)))
  243. }
  244. .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
  245. .navigationTitle("Remote Settings")
  246. .navigationBarTitleDisplayMode(.inline)
  247. }
  248. // MARK: - Custom Row for Remote Type Selection
  249. private func remoteTypeRow(type: RemoteType, label: String, isEnabled: Bool) -> some View {
  250. Button(action: {
  251. if isEnabled {
  252. viewModel.remoteType = type
  253. }
  254. }) {
  255. HStack {
  256. Text(label)
  257. Spacer()
  258. if viewModel.remoteType == type {
  259. Image(systemName: "checkmark")
  260. .foregroundColor(.accentColor)
  261. }
  262. }
  263. }
  264. // If isEnabled is false, user can see the row but not tap it.
  265. .disabled(!isEnabled)
  266. .foregroundColor(isEnabled ? .primary : .gray)
  267. }
  268. // MARK: - Validation Error Handler
  269. private func handleValidationError(_ message: String) {
  270. alertMessage = message
  271. alertType = .validation
  272. showAlert = true
  273. }
  274. private var guardrailsSection: some View {
  275. Section(header: Text("Guardrails")) {
  276. HStack {
  277. Text("Max Bolus")
  278. Spacer()
  279. TextFieldWithToolBar(
  280. quantity: $viewModel.maxBolus,
  281. maxLength: 5,
  282. unit: HKUnit.internationalUnit(),
  283. allowDecimalSeparator: true,
  284. minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
  285. maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 10),
  286. onValidationError: { message in
  287. handleValidationError(message)
  288. }
  289. )
  290. .frame(width: 100)
  291. Text("U")
  292. .foregroundColor(.secondary)
  293. }
  294. HStack {
  295. Text("Max Carbs")
  296. Spacer()
  297. TextFieldWithToolBar(
  298. quantity: $viewModel.maxCarbs,
  299. maxLength: 4,
  300. unit: HKUnit.gram(),
  301. allowDecimalSeparator: true,
  302. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  303. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  304. onValidationError: { message in
  305. handleValidationError(message)
  306. }
  307. )
  308. .frame(width: 100)
  309. Text("g")
  310. .foregroundColor(.secondary)
  311. }
  312. if device.value == "Trio" {
  313. HStack {
  314. Text("Max Protein")
  315. Spacer()
  316. TextFieldWithToolBar(
  317. quantity: $viewModel.maxProtein,
  318. maxLength: 4,
  319. unit: HKUnit.gram(),
  320. allowDecimalSeparator: true,
  321. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  322. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  323. onValidationError: { message in
  324. handleValidationError(message)
  325. }
  326. )
  327. .frame(width: 100)
  328. Text("g")
  329. .foregroundColor(.secondary)
  330. }
  331. HStack {
  332. Text("Max Fat")
  333. Spacer()
  334. TextFieldWithToolBar(
  335. quantity: $viewModel.maxFat,
  336. maxLength: 4,
  337. unit: HKUnit.gram(),
  338. allowDecimalSeparator: true,
  339. minValue: HKQuantity(unit: .gram(), doubleValue: 0),
  340. maxValue: HKQuantity(unit: .gram(), doubleValue: 100),
  341. onValidationError: { message in
  342. handleValidationError(message)
  343. }
  344. )
  345. .frame(width: 100)
  346. Text("g")
  347. .foregroundColor(.secondary)
  348. }
  349. }
  350. }
  351. }
  352. }