SettingsRootView.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import HealthKit
  2. import LoopKit
  3. import LoopKitUI
  4. import SwiftUI
  5. import Swinject
  6. extension Settings {
  7. struct VersionInfo: Equatable {
  8. var latestVersion: String?
  9. var isUpdateAvailable: Bool
  10. var isBlacklisted: Bool
  11. var latestDevVersion: String?
  12. var isDevUpdateAvailable: Bool
  13. }
  14. struct RootView: BaseView {
  15. let resolver: Resolver
  16. @StateObject var state = StateModel()
  17. @State private var showShareSheet = false
  18. @State private var searchText: String = ""
  19. @State private var shouldDisplayHint: Bool = false
  20. @State var hintDetent = PresentationDetent.large
  21. @State var selectedVerboseHint: AnyView?
  22. @State var hintLabel: String?
  23. @State private var decimalPlaceholder: Decimal = 0.0
  24. @State private var booleanPlaceholder: Bool = false
  25. @State private var versionInfo = VersionInfo(
  26. latestVersion: nil,
  27. isUpdateAvailable: false,
  28. isBlacklisted: false,
  29. latestDevVersion: nil,
  30. isDevUpdateAvailable: false
  31. )
  32. @State private var closedLoopDisabled = true
  33. @Environment(\.colorScheme) var colorScheme
  34. @EnvironmentObject var appIcons: Icons
  35. @Environment(AppState.self) var appState
  36. private var filteredItems: [FilteredSettingItem] {
  37. SettingItems.filteredItems(searchText: searchText)
  38. }
  39. @ViewBuilder var versionInfoView: some View {
  40. VStack(alignment: .leading, spacing: 4) {
  41. // Main version info
  42. if let version = versionInfo.latestVersion {
  43. let updateColor: Color = versionInfo.isUpdateAvailable ? .orange : .green
  44. let versionIconName = versionInfo
  45. .isUpdateAvailable ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
  46. HStack {
  47. Text("Latest version: \(version)")
  48. .font(.footnote)
  49. .foregroundColor(updateColor)
  50. Image(systemName: versionIconName)
  51. .foregroundColor(updateColor)
  52. }
  53. if versionInfo.isBlacklisted {
  54. HStack {
  55. Text("Warning: Known issues. Update now.")
  56. .font(.footnote)
  57. .foregroundColor(.red)
  58. Image(systemName: "exclamationmark.octagon.fill")
  59. .foregroundColor(.red)
  60. }
  61. }
  62. } else {
  63. Text("Latest version: Fetching...")
  64. .font(.footnote)
  65. .foregroundColor(.secondary)
  66. }
  67. // Show latest dev version on any branch except main
  68. let buildDetails = BuildDetails.shared
  69. if buildDetails.trioBranch != "main" {
  70. if let devVersion = versionInfo.latestDevVersion {
  71. let devUpdateColor: Color = versionInfo.isDevUpdateAvailable ? .orange : .secondary
  72. let devVersionIconName = versionInfo.isDevUpdateAvailable ? "arrow.up.circle.fill" : "hammer.fill"
  73. HStack {
  74. Text("Latest dev: \(devVersion)")
  75. .font(.footnote)
  76. .foregroundColor(devUpdateColor)
  77. Image(systemName: devVersionIconName)
  78. .font(.footnote)
  79. .foregroundColor(devUpdateColor)
  80. }
  81. } else {
  82. Text("Latest dev: Fetching...")
  83. .font(.footnote)
  84. .foregroundColor(.secondary)
  85. }
  86. }
  87. }
  88. }
  89. var body: some View {
  90. List {
  91. if searchText.isEmpty {
  92. let buildDetails = BuildDetails.shared
  93. Section(
  94. header: Text("BRANCH: \(buildDetails.branchAndSha)").textCase(nil),
  95. content: {
  96. /// The current development version of the app.
  97. ///
  98. /// Follows a semantic pattern where release versions are like `0.5.0`, and
  99. /// development versions increment with a fourth component (e.g., `0.5.0.1`, `0.5.0.2`)
  100. /// after the base release. For example:
  101. /// - After release `0.5.0` → `0.5.0`
  102. /// - First dev push → `0.5.0.1`
  103. /// - Next dev push → `0.5.0.2`
  104. /// - Next release `0.6.0` → `0.6.0`
  105. /// - Next dev push → `0.6.0.1`
  106. ///
  107. /// If the dev version is unavailable, `"unknown"` is returned.
  108. let devVersion = Bundle.main.appDevVersion ?? "unknown"
  109. let buildNumber = Bundle.main.buildVersionNumber ?? String(localized: "Unknown")
  110. NavigationLink(destination: SubmodulesView(buildDetails: buildDetails)) {
  111. HStack {
  112. Image(appIcons.appIcon.rawValue)
  113. .resizable()
  114. .aspectRatio(contentMode: .fit)
  115. .frame(width: 50, height: 50)
  116. .cornerRadius(10)
  117. .padding(.trailing, 10)
  118. VStack(alignment: .leading, spacing: 4) {
  119. Text("Trio v\(devVersion) (\(buildNumber))")
  120. .font(.headline)
  121. if let expirationDate = buildDetails.calculateExpirationDate() {
  122. let formattedDate = DateFormatter.localizedString(
  123. from: expirationDate,
  124. dateStyle: .medium,
  125. timeStyle: .none
  126. )
  127. Text("\(buildDetails.expirationHeaderString): \(formattedDate)")
  128. .font(.footnote)
  129. .foregroundColor(.secondary)
  130. } else {
  131. Text("Simulator Build has no expiry")
  132. .font(.footnote)
  133. .foregroundColor(.secondary)
  134. }
  135. versionInfoView
  136. }
  137. }
  138. }
  139. }
  140. ).listRowBackground(Color.chart)
  141. let miniHintText = closedLoopDisabled ?
  142. String(localized: "Add a CGM and pump to enable automated insulin delivery") :
  143. String(localized: "Enable automated insulin delivery.")
  144. let miniHintTextColorForDisabled: Color = colorScheme == .dark ? .orange : .accentColor
  145. let miniHintTextColor: Color = closedLoopDisabled ? miniHintTextColorForDisabled : .secondary
  146. SettingInputSection(
  147. decimalValue: $decimalPlaceholder,
  148. booleanValue: $state.closedLoop,
  149. shouldDisplayHint: $shouldDisplayHint,
  150. selectedVerboseHint: Binding(
  151. get: { selectedVerboseHint },
  152. set: {
  153. selectedVerboseHint = $0.map { AnyView($0) }
  154. hintLabel = String(localized: "Closed Loop")
  155. }
  156. ),
  157. units: state.units,
  158. type: .boolean,
  159. label: String(localized: "Closed Loop"),
  160. miniHint: miniHintText,
  161. verboseHint: VStack(alignment: .leading, spacing: 10) {
  162. Text(
  163. "Running Trio in closed loop mode requires an active CGM sensor session and a connected pump. This enables automated insulin delivery."
  164. )
  165. Text(
  166. "Before enabling, dial in your settings (basal / insulin sensitivity / carb ratio), and familiarize yourself with the app."
  167. )
  168. },
  169. headerText: String(localized: "Automated Insulin Delivery"),
  170. isToggleDisabled: closedLoopDisabled,
  171. miniHintColor: miniHintTextColor
  172. )
  173. .onAppear {
  174. closedLoopDisabled = !state.hasCgmAndPump()
  175. }
  176. Section(
  177. header: Text("Trio Configuration"),
  178. content: {
  179. ForEach(SettingItems.trioConfig) { item in
  180. Text(LocalizedStringKey(item.title)).navigationLink(to: item.view, from: self)
  181. }
  182. }
  183. )
  184. .listRowBackground(Color.chart)
  185. Section(
  186. header: Text("Support & Community"),
  187. content: {
  188. Button {
  189. showShareSheet.toggle()
  190. } label: {
  191. HStack {
  192. Text("Share Logs")
  193. .foregroundColor(.primary)
  194. Spacer()
  195. Image(systemName: "chevron.right")
  196. .foregroundColor(.secondary)
  197. .font(.footnote)
  198. }
  199. }
  200. .frame(maxWidth: .infinity, alignment: .leading)
  201. Button {
  202. if let url = URL(string: "https://github.com/nightscout/Trio/issues/new/choose") {
  203. UIApplication.shared.open(url)
  204. }
  205. } label: {
  206. HStack {
  207. Text("Submit Ticket on GitHub")
  208. .foregroundColor(.primary)
  209. Spacer()
  210. Image(systemName: "chevron.right")
  211. .foregroundColor(.secondary)
  212. .font(.footnote)
  213. }
  214. }
  215. .frame(maxWidth: .infinity, alignment: .leading)
  216. Button {
  217. if let url = URL(string: "https://discord.triodocs.org") {
  218. UIApplication.shared.open(url)
  219. }
  220. } label: {
  221. HStack {
  222. Text("Trio Discord")
  223. .foregroundColor(.primary)
  224. Spacer()
  225. Image(systemName: "chevron.right")
  226. .foregroundColor(.secondary)
  227. .font(.footnote)
  228. }
  229. }
  230. .frame(maxWidth: .infinity, alignment: .leading)
  231. Button {
  232. if let url = URL(string: "https://facebook.triodocs.org") {
  233. UIApplication.shared.open(url)
  234. }
  235. } label: {
  236. HStack {
  237. Text("Trio Facebook")
  238. .foregroundColor(.primary)
  239. Spacer()
  240. Image(systemName: "chevron.right")
  241. .foregroundColor(.secondary)
  242. .font(.footnote)
  243. }
  244. }
  245. .frame(maxWidth: .infinity, alignment: .leading)
  246. }
  247. ).listRowBackground(Color.chart)
  248. } else {
  249. Section(
  250. header: Text("Search Results"),
  251. content: {
  252. if filteredItems.isNotEmpty {
  253. ForEach(filteredItems) { filteredItem in
  254. VStack(alignment: .leading) {
  255. Text(filteredItem.matchedContent.localized).bold()
  256. if let path = filteredItem.settingItem.path {
  257. Text(path.map(\.localized).joined(separator: " > "))
  258. .font(.caption)
  259. .foregroundColor(.secondary)
  260. }
  261. }.navigationLink(to: filteredItem.settingItem.view, from: self)
  262. }
  263. } else {
  264. Text("No settings matching your search query")
  265. +
  266. Text(" »\(searchText)« ").bold()
  267. +
  268. Text("found.")
  269. }
  270. }
  271. ).listRowBackground(Color.chart)
  272. }
  273. // TODO: remove this more or less entirely; add build-time flag to enable Middleware; add settings export feature
  274. // Section {
  275. // Toggle("Developer Options", isOn: $state.debugOptions)
  276. // if state.debugOptions {
  277. // Group {
  278. // HStack {
  279. // Text("NS Upload Profile and Settings")
  280. // Button("Upload") { state.uploadProfileAndSettings(true) }
  281. // .frame(maxWidth: .infinity, alignment: .trailing)
  282. // .buttonStyle(.borderedProminent)
  283. // }
  284. // // Commenting this out for now, as not needed and possibly dangerous for users to be able to nuke their pump pairing informations via the debug menu
  285. // // Leaving it in here, as it may be a handy functionality for further testing or developers.
  286. // // See https://github.com/nightscout/Trio/pull/277 for more information
  287. // //
  288. // // HStack {
  289. // // Text("Delete Stored Pump State Binary Files")
  290. // // Button("Delete") { state.resetLoopDocuments() }
  291. // // .frame(maxWidth: .infinity, alignment: .trailing)
  292. // // .buttonStyle(.borderedProminent)
  293. // // }
  294. // }
  295. // Group {
  296. // Text("Preferences")
  297. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.preferences), from: self)
  298. // Text("Pump Settings")
  299. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.settings), from: self)
  300. // Text("Autosense")
  301. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.autosense), from: self)
  302. // // Text("Pump History")
  303. // // .navigationLink(to: .configEditor(file: OpenAPS.Monitor.pumpHistory), from: self)
  304. // Text("Basal profile")
  305. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.basalProfile), from: self)
  306. // Text("Targets ranges")
  307. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.bgTargets), from: self)
  308. // Text("Temp targets")
  309. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.tempTargets), from: self)
  310. // }
  311. //
  312. // Group {
  313. // Text("Pump profile")
  314. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.pumpProfile), from: self)
  315. // Text("Profile")
  316. // .navigationLink(to: .configEditor(file: OpenAPS.Settings.profile), from: self)
  317. // // Text("Carbs")
  318. // // .navigationLink(to: .configEditor(file: OpenAPS.Monitor.carbHistory), from: self)
  319. // }
  320. //
  321. // Group {
  322. // Text("Target presets")
  323. // .navigationLink(to: .configEditor(file: OpenAPS.Trio.tempTargetsPresets), from: self)
  324. // Text("Calibrations")
  325. // .navigationLink(to: .configEditor(file: OpenAPS.Trio.calibrations), from: self)
  326. // Text("Middleware")
  327. // .navigationLink(to: .configEditor(file: OpenAPS.Middleware.determineBasal), from: self)
  328. // // Text("Statistics")
  329. // // .navigationLink(to: .configEditor(file: OpenAPS.Monitor.statistics), from: self)
  330. // Text("Edit settings json")
  331. // .navigationLink(to: .configEditor(file: OpenAPS.Trio.settings), from: self)
  332. // }
  333. // }
  334. // }.listRowBackground(Color.chart)
  335. }
  336. .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
  337. .sheet(isPresented: $shouldDisplayHint) {
  338. SettingInputHintView(
  339. hintDetent: $hintDetent,
  340. shouldDisplayHint: $shouldDisplayHint,
  341. hintLabel: hintLabel ?? "",
  342. hintText: selectedVerboseHint ?? AnyView(EmptyView()),
  343. sheetTitle: String(localized: "Help", comment: "Help sheet title")
  344. )
  345. }
  346. .sheet(isPresented: $showShareSheet) {
  347. ShareSheet(activityItems: state.logItems())
  348. }
  349. .onAppear(perform: configureView)
  350. .navigationTitle("Settings")
  351. .navigationBarTitleDisplayMode(.automatic)
  352. .toolbar {
  353. ToolbarItem(placement: .topBarTrailing) {
  354. Button(
  355. action: {
  356. if let url = URL(string: "https://triodocs.org/") {
  357. UIApplication.shared.open(url)
  358. }
  359. },
  360. label: {
  361. HStack {
  362. Text("Trio Docs")
  363. Image(systemName: "questionmark.circle")
  364. }
  365. }
  366. )
  367. }
  368. }
  369. .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
  370. .screenNavigation(self)
  371. .onAppear {
  372. Task { @MainActor in
  373. let (_, latestVersion, isNewer, isBlacklisted) = await AppVersionChecker.shared.refreshVersionInfo()
  374. versionInfo.latestVersion = latestVersion
  375. versionInfo.isUpdateAvailable = isNewer
  376. versionInfo.isBlacklisted = isBlacklisted
  377. // Fetch dev version if not on main branch
  378. let buildDetails = BuildDetails.shared
  379. if buildDetails.trioBranch != "main" {
  380. let (devVersion, isDevNewer) = await AppVersionChecker.shared.checkForNewDevVersion()
  381. versionInfo.latestDevVersion = devVersion
  382. versionInfo.isDevUpdateAvailable = isDevNewer
  383. }
  384. }
  385. }
  386. }
  387. }
  388. }