SettingsRootView.swift 19 KB

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