SettingsRootView.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  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. @Environment(SettingsSearchHighlight.self) var searchHighlight
  37. private var filteredItems: [FilteredSettingItem] {
  38. SettingItems.filteredItems(searchText: searchText)
  39. }
  40. @ViewBuilder var versionInfoView: some View {
  41. VStack(alignment: .leading, spacing: 4) {
  42. // Main version info
  43. if let version = versionInfo.latestVersion {
  44. let updateColor: Color = versionInfo.isUpdateAvailable ? .orange : .green
  45. let versionIconName = versionInfo
  46. .isUpdateAvailable ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
  47. HStack {
  48. Text("Latest version: \(version)")
  49. .font(.footnote)
  50. .foregroundColor(updateColor)
  51. Image(systemName: versionIconName)
  52. .foregroundColor(updateColor)
  53. }
  54. if versionInfo.isBlacklisted {
  55. HStack {
  56. Text("Warning: Known issues. Update now.")
  57. .font(.footnote)
  58. .foregroundColor(.red)
  59. Image(systemName: "exclamationmark.octagon.fill")
  60. .foregroundColor(.red)
  61. }
  62. }
  63. } else {
  64. Text("Latest version: Fetching...")
  65. .font(.footnote)
  66. .foregroundColor(.secondary)
  67. }
  68. // Show latest dev version on any branch except main
  69. let buildDetails = BuildDetails.shared
  70. if buildDetails.trioBranch != "main" {
  71. if let devVersion = versionInfo.latestDevVersion {
  72. let devUpdateColor: Color = versionInfo.isDevUpdateAvailable ? .orange : .secondary
  73. let devVersionIconName = versionInfo.isDevUpdateAvailable ? "arrow.up.circle.fill" : "hammer.fill"
  74. HStack {
  75. Text("Latest dev: \(devVersion)")
  76. .font(.footnote)
  77. .foregroundColor(devUpdateColor)
  78. Image(systemName: devVersionIconName)
  79. .font(.footnote)
  80. .foregroundColor(devUpdateColor)
  81. }
  82. } else {
  83. Text("Latest dev: Fetching...")
  84. .font(.footnote)
  85. .foregroundColor(.secondary)
  86. }
  87. }
  88. }
  89. }
  90. var body: some View {
  91. List {
  92. if searchText.isEmpty {
  93. let buildDetails = BuildDetails.shared
  94. Section(
  95. header: Text("BRANCH: \(buildDetails.branchAndSha)").textCase(nil),
  96. content: {
  97. /// The current development version of the app.
  98. ///
  99. /// Follows a semantic pattern where release versions are like `0.5.0`, and
  100. /// development versions increment with a fourth component (e.g., `0.5.0.1`, `0.5.0.2`)
  101. /// after the base release. For example:
  102. /// - After release `0.5.0` → `0.5.0`
  103. /// - First dev push → `0.5.0.1`
  104. /// - Next dev push → `0.5.0.2`
  105. /// - Next release `0.6.0` → `0.6.0`
  106. /// - Next dev push → `0.6.0.1`
  107. ///
  108. /// If the dev version is unavailable, `"unknown"` is returned.
  109. let devVersion = Bundle.main.appDevVersion ?? "unknown"
  110. let buildNumber = Bundle.main.buildVersionNumber ?? String(localized: "Unknown")
  111. NavigationLink(destination: SubmodulesView(buildDetails: buildDetails)) {
  112. HStack {
  113. Image(appIcons.appIcon.rawValue)
  114. .resizable()
  115. .aspectRatio(contentMode: .fit)
  116. .frame(width: 50, height: 50)
  117. .cornerRadius(10)
  118. .padding(.trailing, 10)
  119. VStack(alignment: .leading, spacing: 4) {
  120. Text("Trio v\(devVersion) (\(buildNumber))")
  121. .font(.headline)
  122. if let expirationDate = buildDetails.calculateExpirationDate() {
  123. let formattedDate = DateFormatter.localizedString(
  124. from: expirationDate,
  125. dateStyle: .medium,
  126. timeStyle: .none
  127. )
  128. Text("\(buildDetails.expirationHeaderString): \(formattedDate)")
  129. .font(.footnote)
  130. .foregroundColor(.secondary)
  131. } else {
  132. Text("Simulator Build has no expiry")
  133. .font(.footnote)
  134. .foregroundColor(.secondary)
  135. }
  136. versionInfoView
  137. }
  138. }
  139. }
  140. }
  141. ).listRowBackground(Color.chart)
  142. let miniHintText = closedLoopDisabled ?
  143. String(localized: "Add a CGM and pump to enable automated insulin delivery") :
  144. String(localized: "Enable automated insulin delivery.")
  145. let miniHintTextColorForDisabled: Color = colorScheme == .dark ? .orange : .accentColor
  146. let miniHintTextColor: Color = closedLoopDisabled ? miniHintTextColorForDisabled : .secondary
  147. SettingInputSection(
  148. decimalValue: $decimalPlaceholder,
  149. booleanValue: $state.closedLoop,
  150. shouldDisplayHint: $shouldDisplayHint,
  151. selectedVerboseHint: Binding(
  152. get: { selectedVerboseHint },
  153. set: {
  154. selectedVerboseHint = $0.map { AnyView($0) }
  155. hintLabel = String(localized: "Closed Loop")
  156. }
  157. ),
  158. units: state.units,
  159. type: .boolean,
  160. label: String(localized: "Closed Loop"),
  161. miniHint: miniHintText,
  162. verboseHint: VStack(alignment: .leading, spacing: 10) {
  163. Text(
  164. "Running Trio in closed loop mode requires an active CGM sensor session and a connected pump. This enables automated insulin delivery."
  165. )
  166. Text(
  167. "Before enabling, dial in your settings (basal / insulin sensitivity / carb ratio), and familiarize yourself with the app."
  168. )
  169. },
  170. headerText: String(localized: "Automated Insulin Delivery"),
  171. isToggleDisabled: closedLoopDisabled,
  172. miniHintColor: miniHintTextColor
  173. )
  174. .onAppear {
  175. closedLoopDisabled = !state.hasCgmAndPump()
  176. }
  177. Section(
  178. header: Text("Trio Configuration"),
  179. content: {
  180. ForEach(SettingItems.trioConfig) { item in
  181. Text(LocalizedStringKey(item.title)).navigationLink(to: item.view, from: self)
  182. }
  183. }
  184. )
  185. .listRowBackground(Color.chart)
  186. Section(
  187. header: Text("Support & Community"),
  188. content: {
  189. Button {
  190. showShareSheet.toggle()
  191. } label: {
  192. HStack {
  193. Text("Share Logs")
  194. .foregroundColor(.primary)
  195. Spacer()
  196. Image(systemName: "chevron.right")
  197. .foregroundColor(.secondary)
  198. .font(.footnote)
  199. }
  200. }
  201. .frame(maxWidth: .infinity, alignment: .leading)
  202. Button {
  203. if let url = URL(string: "https://github.com/nightscout/Trio/issues/new/choose") {
  204. UIApplication.shared.open(url)
  205. }
  206. } label: {
  207. HStack {
  208. Text("Submit Ticket on GitHub")
  209. .foregroundColor(.primary)
  210. Spacer()
  211. Image(systemName: "chevron.right")
  212. .foregroundColor(.secondary)
  213. .font(.footnote)
  214. }
  215. }
  216. .frame(maxWidth: .infinity, alignment: .leading)
  217. Button {
  218. if let url = URL(string: "https://discord.triodocs.org") {
  219. UIApplication.shared.open(url)
  220. }
  221. } label: {
  222. HStack {
  223. Text("Trio Discord")
  224. .foregroundColor(.primary)
  225. Spacer()
  226. Image(systemName: "chevron.right")
  227. .foregroundColor(.secondary)
  228. .font(.footnote)
  229. }
  230. }
  231. .frame(maxWidth: .infinity, alignment: .leading)
  232. Button {
  233. if let url = URL(string: "https://facebook.triodocs.org") {
  234. UIApplication.shared.open(url)
  235. }
  236. } label: {
  237. HStack {
  238. Text("Trio Facebook")
  239. .foregroundColor(.primary)
  240. Spacer()
  241. Image(systemName: "chevron.right")
  242. .foregroundColor(.secondary)
  243. .font(.footnote)
  244. }
  245. }
  246. .frame(maxWidth: .infinity, alignment: .leading)
  247. }
  248. ).listRowBackground(Color.chart)
  249. Section(
  250. header: Text("Trio Backup"),
  251. content: {
  252. Text(String(
  253. localized: "Export Settings",
  254. comment: "Export Settings menu item in Trio Settings Root View"
  255. ))
  256. .navigationLink(to: .settingsExport, from: self)
  257. }
  258. ).listRowBackground(Color.chart)
  259. } else {
  260. Section(
  261. header: Text("Search Results"),
  262. content: {
  263. if filteredItems.isNotEmpty {
  264. ForEach(filteredItems) { filteredItem in
  265. NavigationLink(value: SearchResultTarget(
  266. screen: filteredItem.settingItem.view,
  267. scrollLabel: filteredItem.scrollLabel.localized
  268. )) {
  269. VStack(alignment: .leading) {
  270. Text(filteredItem.matchedContent.localized).bold()
  271. if let path = filteredItem.settingItem.path {
  272. Text(path.map(\.localized).joined(separator: " > "))
  273. .font(.caption)
  274. .foregroundColor(.secondary)
  275. }
  276. }
  277. }
  278. }
  279. } else {
  280. Text("No settings matching your search query")
  281. +
  282. Text(" »\(searchText)« ").bold()
  283. +
  284. Text("found.")
  285. }
  286. }
  287. ).listRowBackground(Color.chart)
  288. }
  289. }
  290. .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
  291. .sheet(isPresented: $shouldDisplayHint) {
  292. SettingInputHintView(
  293. hintDetent: $hintDetent,
  294. shouldDisplayHint: $shouldDisplayHint,
  295. hintLabel: hintLabel ?? "",
  296. hintText: selectedVerboseHint ?? AnyView(EmptyView()),
  297. sheetTitle: String(localized: "Help", comment: "Help sheet title")
  298. )
  299. }
  300. .sheet(isPresented: $showShareSheet) {
  301. ShareSheet(activityItems: state.logItems())
  302. }
  303. .onAppear(perform: configureView)
  304. .navigationTitle("Settings")
  305. .navigationBarTitleDisplayMode(.automatic)
  306. .toolbar {
  307. ToolbarItem(placement: .topBarTrailing) {
  308. Button(
  309. action: {
  310. if let url = URL(string: "https://triodocs.org/") {
  311. UIApplication.shared.open(url)
  312. }
  313. },
  314. label: {
  315. HStack {
  316. Text("Trio Docs")
  317. Image(systemName: "questionmark.circle")
  318. }
  319. }
  320. )
  321. }
  322. }
  323. .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
  324. .navigationDestination(for: SearchResultTarget.self) { target in
  325. state.view(for: target.screen)
  326. .onAppear {
  327. searchHighlight.highlightedSetting = target.scrollLabel
  328. }
  329. }
  330. .screenNavigation(self)
  331. .onAppear {
  332. Task { @MainActor in
  333. let (_, latestVersion, isNewer, isBlacklisted) = await AppVersionChecker.shared.refreshVersionInfo()
  334. versionInfo.latestVersion = latestVersion
  335. versionInfo.isUpdateAvailable = isNewer
  336. versionInfo.isBlacklisted = isBlacklisted
  337. // Fetch dev version if not on main branch
  338. let buildDetails = BuildDetails.shared
  339. if buildDetails.trioBranch != "main" {
  340. let (devVersion, isDevNewer) = await AppVersionChecker.shared.checkForNewDevVersion()
  341. versionInfo.latestDevVersion = devVersion
  342. versionInfo.isDevUpdateAvailable = isDevNewer
  343. }
  344. }
  345. }
  346. }
  347. }
  348. }