SettingsViewController.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. //
  2. // SettingsViewController.swift
  3. // LoopFollow
  4. //
  5. // Created by Jon Fawcett on 6/3/20.
  6. // Copyright © 2020 Jon Fawcett. All rights reserved.
  7. //
  8. import UIKit
  9. import Eureka
  10. import EventKit
  11. import EventKitUI
  12. class SettingsViewController: FormViewController {
  13. var tokenRow: TextRow?
  14. var appStateController: AppStateController?
  15. var statusLabelRow: LabelRow!
  16. func showHideNSDetails() {
  17. var isHidden = false
  18. var isEnabled = true
  19. var isLoopHidden = false;
  20. if UserDefaultsRepository.url.value == "" || !UserDefaultsRepository.loopUser.value {
  21. isHidden = true
  22. isEnabled = false
  23. }
  24. if let row1 = form.rowBy(tag: "informationDisplaySettings") as? ButtonRow {
  25. row1.hidden = .function(["hide"], {form in
  26. return isHidden
  27. })
  28. row1.evaluateHidden()
  29. }
  30. if UserDefaultsRepository.url.value != "" {
  31. isEnabled = true
  32. }
  33. guard let nightscoutTab = self.tabBarController?.tabBar.items![3] else { return }
  34. nightscoutTab.isEnabled = isEnabled
  35. }
  36. // Determine if the build is from TestFlight
  37. func isTestFlightBuild() -> Bool {
  38. #if targetEnvironment(simulator)
  39. return false
  40. #else
  41. if Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") != nil {
  42. return false
  43. }
  44. guard let receiptName = Bundle.main.appStoreReceiptURL?.lastPathComponent else {
  45. return false
  46. }
  47. return "sandboxReceipt".caseInsensitiveCompare(receiptName) == .orderedSame
  48. #endif
  49. }
  50. // Get the build date from the build details
  51. func buildDate() -> Date? {
  52. let dateFormatter = DateFormatter()
  53. dateFormatter.dateFormat = "EEE MMM d HH:mm:ss 'UTC' yyyy"
  54. dateFormatter.locale = Locale(identifier: "en_US_POSIX")
  55. dateFormatter.timeZone = TimeZone(identifier: "UTC")
  56. guard let dateString = BuildDetails.default.buildDateString,
  57. let date = dateFormatter.date(from: dateString) else {
  58. return nil
  59. }
  60. return date
  61. }
  62. // Calculate the expiration date based on the build type
  63. func calculateExpirationDate() -> Date {
  64. if isTestFlightBuild(), let buildDate = buildDate() {
  65. // For TestFlight, add 90 days to the build date
  66. return Calendar.current.date(byAdding: .day, value: 90, to: buildDate)!
  67. } else {
  68. // For Xcode builds, use the provisioning profile's expiration date
  69. if let provision = MobileProvision.read() {
  70. return provision.expirationDate
  71. } else {
  72. return Date() // Fallback to current date if unable to read provisioning profile
  73. }
  74. }
  75. }
  76. override func viewDidLoad() {
  77. super.viewDidLoad()
  78. if UserDefaultsRepository.forceDarkMode.value {
  79. overrideUserInterfaceStyle = .dark
  80. }
  81. UserDefaultsRepository.showNS.value = false
  82. UserDefaultsRepository.showDex.value = false
  83. let expiration = calculateExpirationDate()
  84. var expirationHeaderString = "App Expiration"
  85. if isTestFlightBuild() {
  86. expirationHeaderString = "Beta (TestFlight) Expiration"
  87. }
  88. form
  89. +++ Section(header: "Data Settings", footer: "")
  90. <<< SegmentedRow<String>("units") { row in
  91. row.title = "Units"
  92. row.options = ["mg/dL", "mmol/L"]
  93. row.value = UserDefaultsRepository.units.value
  94. }.onChange { row in
  95. guard let value = row.value else { return }
  96. UserDefaultsRepository.units.value = value
  97. }
  98. <<< SwitchRow("showNS"){ row in
  99. row.title = "Show Nightscout Settings"
  100. row.value = UserDefaultsRepository.showNS.value
  101. }.onChange { [weak self] row in
  102. guard let value = row.value else { return }
  103. UserDefaultsRepository.showNS.value = value
  104. }
  105. <<< TextRow() { row in
  106. row.title = "URL"
  107. row.placeholder = "https://mycgm.herokuapp.com"
  108. row.value = UserDefaultsRepository.url.value
  109. row.hidden = "$showNS == false"
  110. }.cellSetup { (cell, row) in
  111. cell.textField.autocorrectionType = .no
  112. cell.textField.autocapitalizationType = .none
  113. }.onChange { row in
  114. guard let value = row.value else {
  115. UserDefaultsRepository.url.value = ""
  116. self.showHideNSDetails()
  117. return
  118. }
  119. var useTokenUrl = false
  120. // Attempt to handle special case: pasted URL including token
  121. if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
  122. if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) {
  123. let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$"
  124. if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) {
  125. var baseComponents = urlComponents
  126. baseComponents.queryItems = nil
  127. if let baseURL = baseComponents.string {
  128. UserDefaultsRepository.token.value = token
  129. self.tokenRow?.value = token
  130. self.tokenRow?.updateCell()
  131. UserDefaultsRepository.url.value = baseURL
  132. row.value = baseURL
  133. row.updateCell()
  134. useTokenUrl = true
  135. }
  136. }
  137. }
  138. }
  139. if !useTokenUrl {
  140. // Normalize input: remove unwanted characters and lowercase
  141. let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased()
  142. // Further clean-up: Remove trailing slashes
  143. var cleanURL = filtered
  144. while cleanURL.last == "/" {
  145. cleanURL = String(cleanURL.dropLast())
  146. }
  147. UserDefaultsRepository.url.value = cleanURL
  148. row.value = cleanURL
  149. row.updateCell()
  150. }
  151. self.showHideNSDetails()
  152. globalVariables.nsVerifiedAlert = 0
  153. // Verify Nightscout URL and token
  154. self.checkNightscoutStatus()
  155. }
  156. <<< TextRow() { row in
  157. row.title = "NS Token"
  158. row.placeholder = "Leave blank if not using tokens"
  159. row.value = UserDefaultsRepository.token.value
  160. row.hidden = "$showNS == false"
  161. self.tokenRow = row
  162. }.cellSetup { (cell, row) in
  163. cell.textField.autocorrectionType = .no
  164. cell.textField.autocapitalizationType = .none
  165. cell.textField.textContentType = .password
  166. }.onChange { row in
  167. if row.value == nil {
  168. UserDefaultsRepository.token.value = ""
  169. }
  170. guard let value = row.value else { return }
  171. UserDefaultsRepository.token.value = value
  172. globalVariables.nsVerifiedAlert = 0
  173. // Verify Nightscout URL and token
  174. self.checkNightscoutStatus()
  175. }
  176. <<< LabelRow() { row in
  177. row.title = "NS Status"
  178. row.value = "Checking..."
  179. statusLabelRow = row
  180. row.hidden = "$showNS == false"
  181. }
  182. <<< SwitchRow("loopUser"){ row in
  183. row.title = "Download Loop/iAPS Data"
  184. row.tag = "loopUser"
  185. row.value = UserDefaultsRepository.loopUser.value
  186. row.hidden = "$showNS == false"
  187. }.onChange { row in
  188. guard let value = row.value else { return }
  189. UserDefaultsRepository.loopUser.value = value
  190. }
  191. <<< SwitchRow("showDex"){ row in
  192. row.title = "Show Dexcom Settings"
  193. row.value = UserDefaultsRepository.showDex.value
  194. }.onChange { row in
  195. guard let value = row.value else { return }
  196. UserDefaultsRepository.showDex.value = value
  197. }
  198. <<< TextRow(){ row in
  199. row.title = "User Name"
  200. row.value = UserDefaultsRepository.shareUserName.value
  201. row.hidden = "$showDex == false"
  202. }.cellSetup { (cell, row) in
  203. cell.textField.autocorrectionType = .no
  204. cell.textField.autocapitalizationType = .none
  205. }.onChange { row in
  206. if row.value == nil {
  207. UserDefaultsRepository.shareUserName.value = ""
  208. }
  209. guard let value = row.value else { return }
  210. UserDefaultsRepository.shareUserName.value = value
  211. globalVariables.dexVerifiedAlert = 0
  212. }
  213. <<< TextRow(){ row in
  214. row.title = "Password"
  215. row.value = UserDefaultsRepository.sharePassword.value
  216. row.hidden = "$showDex == false"
  217. }.cellSetup { (cell, row) in
  218. cell.textField.autocorrectionType = .no
  219. cell.textField.isSecureTextEntry = true
  220. cell.textField.autocapitalizationType = .none
  221. }.onChange { row in
  222. if row.value == nil {
  223. UserDefaultsRepository.sharePassword.value = ""
  224. }
  225. guard let value = row.value else { return }
  226. UserDefaultsRepository.sharePassword.value = value
  227. globalVariables.dexVerifiedAlert = 0
  228. }
  229. <<< SegmentedRow<String>("shareServer") { row in
  230. row.title = "Server"
  231. row.options = ["US", "NON-US"]
  232. row.value = UserDefaultsRepository.shareServer.value
  233. row.hidden = "$showDex == false"
  234. }.onChange { row in
  235. guard let value = row.value else { return }
  236. UserDefaultsRepository.shareServer.value = value
  237. }
  238. +++ Section("App Settings")
  239. <<< ButtonRow() {
  240. $0.title = "General Settings"
  241. $0.presentationMode = .show(
  242. controllerProvider: .callback(builder: {
  243. let controller = GeneralSettingsViewController()
  244. controller.appStateController = self.appStateController
  245. return controller
  246. }
  247. ), onDismiss: nil)
  248. }
  249. <<< ButtonRow("graphSettings") {
  250. $0.title = "Graph Settings"
  251. $0.presentationMode = .show(
  252. controllerProvider: .callback(builder: {
  253. let controller = GraphSettingsViewController()
  254. controller.appStateController = self.appStateController
  255. return controller
  256. }
  257. ), onDismiss: nil)
  258. }
  259. <<< ButtonRow("informationDisplaySettings") {
  260. $0.title = "Information Display Settings"
  261. $0.presentationMode = .show(
  262. controllerProvider: .callback(builder: {
  263. let controller = InfoDisplaySettingsViewController()
  264. controller.appStateController = self.appStateController
  265. return controller
  266. }
  267. ), onDismiss: nil)
  268. }
  269. +++ Section("Integrations")
  270. <<< ButtonRow() {
  271. $0.title = "Apple Watch and Carplay"
  272. $0.presentationMode = .show(
  273. controllerProvider: .callback(builder: {
  274. let controller = WatchSettingsViewController()
  275. controller.appStateController = self.appStateController
  276. return controller
  277. }
  278. ), onDismiss: nil)
  279. }
  280. +++ Section("Advanced Settings")
  281. <<< ButtonRow() {
  282. $0.title = "Advanced Settings"
  283. $0.presentationMode = .show(
  284. controllerProvider: .callback(builder: {
  285. let controller = AdvancedSettingsViewController()
  286. controller.appStateController = self.appStateController
  287. return controller
  288. }
  289. ), onDismiss: nil)
  290. }
  291. +++ Section(header: getAppVersion(), footer: "")
  292. if !isMacApp() {
  293. form +++ Section(header: expirationHeaderString, footer: String(expiration.description))
  294. }
  295. showHideNSDetails()
  296. checkNightscoutStatus()
  297. }
  298. func isMacApp() -> Bool {
  299. #if targetEnvironment(macCatalyst)
  300. return true
  301. #else
  302. return false
  303. #endif
  304. }
  305. func getAppVersion() -> String {
  306. if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
  307. return "App Version: \(version)"
  308. }
  309. return "Version Unknown"
  310. }
  311. func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
  312. if let error = error {
  313. switch error {
  314. case .invalidURL:
  315. statusLabelRow.value = "Invalid URL"
  316. case .networkError:
  317. statusLabelRow.value = "Network Error"
  318. case .invalidToken:
  319. statusLabelRow.value = "Invalid Token"
  320. case .tokenRequired:
  321. statusLabelRow.value = "Token Required"
  322. case .siteNotFound:
  323. statusLabelRow.value = "Site Not Found"
  324. case .unknown:
  325. statusLabelRow.value = "Unknown Error"
  326. case .emptyAddress:
  327. statusLabelRow.value = "Address Empty"
  328. }
  329. } else {
  330. statusLabelRow.value = "OK"
  331. NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
  332. }
  333. statusLabelRow.updateCell()
  334. }
  335. func checkNightscoutStatus() {
  336. NightscoutUtils.verifyURLAndToken(urlUser: UserDefaultsRepository.url.value, token: UserDefaultsRepository.token.value) { error in
  337. DispatchQueue.main.async {
  338. self.updateStatusLabel(error: error)
  339. }
  340. }
  341. }
  342. }