MoreMenuView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. // LoopFollow
  2. // MoreMenuView.swift
  3. import SwiftUI
  4. import UIKit
  5. struct MoreMenuView: View {
  6. @State private var pendingRoute: MenuRoute?
  7. @State private var latestVersion: String?
  8. @State private var versionTint: Color = .secondary
  9. @State private var alertTitle = ""
  10. @State private var alertMessage = ""
  11. @State private var showAlert = false
  12. @State private var currentVersion: String = AppVersionManager().version()
  13. @State private var searchText = ""
  14. @ObservedObject private var nightscoutURL = Storage.shared.url
  15. private var isSearching: Bool {
  16. !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
  17. }
  18. var body: some View {
  19. List {
  20. if isSearching {
  21. searchResultsSection
  22. } else {
  23. menuContent
  24. }
  25. }
  26. .listStyle(.insetGrouped)
  27. .navigationTitle("Menu")
  28. .navigationBarTitleDisplayMode(.large)
  29. .searchable(text: $searchText, prompt: "Search")
  30. .overlay {
  31. if isSearching, filteredSearchItems.isEmpty {
  32. VStack(spacing: 8) {
  33. Image(systemName: "magnifyingglass")
  34. .font(.largeTitle)
  35. .foregroundColor(.secondary)
  36. Text("No Results")
  37. .font(.headline)
  38. Text("No matches for “\(searchText)”.")
  39. .font(.subheadline)
  40. .foregroundColor(.secondary)
  41. .multilineTextAlignment(.center)
  42. }
  43. .padding(.horizontal)
  44. }
  45. }
  46. .task {
  47. await fetchVersionInfo()
  48. }
  49. .alert(alertTitle, isPresented: $showAlert) {
  50. Button("OK", role: .cancel) {}
  51. } message: {
  52. Text(alertMessage)
  53. }
  54. .navigationDestination(for: SettingsRoute.self) { $0.destination }
  55. .navigationDestination(
  56. isPresented: Binding(
  57. get: { pendingRoute != nil },
  58. set: { if !$0 { pendingRoute = nil } }
  59. )
  60. ) {
  61. if let route = pendingRoute {
  62. route.destination
  63. }
  64. }
  65. }
  66. // MARK: - Menu content
  67. @ViewBuilder
  68. private var menuContent: some View {
  69. // Settings
  70. Section {
  71. NavigationLink(value: SettingsRoute.settings) {
  72. Label("Settings", systemImage: "gearshape")
  73. }
  74. }
  75. // Features
  76. Section("Features") {
  77. ForEach(TabItem.featureOrder) { item in
  78. FullRowButton(showsChevron: true) {
  79. selectFeature(item)
  80. } label: {
  81. Label(item.displayName, systemImage: item.icon)
  82. }
  83. }
  84. }
  85. // Logging
  86. Section("Logging") {
  87. FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
  88. Label("View Log", systemImage: "doc.text.magnifyingglass")
  89. }
  90. FullRowButton { shareLogs() } label: {
  91. Label("Share Logs", systemImage: "square.and.arrow.up")
  92. }
  93. }
  94. // Support & Community
  95. Section("Support & Community") {
  96. ForEach(MoreMenuView.supportLinks, id: \.url) { link in
  97. Link(destination: link.url) {
  98. HStack {
  99. Label(link.title, systemImage: link.icon)
  100. Spacer()
  101. Image(systemName: "arrow.up.right.square")
  102. .foregroundStyle(.tertiary)
  103. }
  104. }
  105. }
  106. }
  107. // Build Information
  108. Section("Build Information") {
  109. buildInfoRow(title: "Version", value: currentVersion, color: versionTint)
  110. buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary)
  111. let build = BuildDetails.default
  112. if !(build.isMacApp() || build.isSimulatorBuild()) {
  113. buildInfoRow(
  114. title: build.expirationHeaderString,
  115. value: dateTimeUtils.formattedDate(from: build.calculateExpirationDate()),
  116. color: .secondary
  117. )
  118. }
  119. buildInfoRow(title: "Built", value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary)
  120. buildInfoRow(title: "Branch", value: build.branchAndSha, color: .secondary)
  121. }
  122. }
  123. private func selectFeature(_ item: TabItem) {
  124. let tabs = Storage.shared.orderedTabBarItems()
  125. if let tabIndex = tabs.firstIndex(of: item) {
  126. Observable.shared.selectedTabIndex.value = tabIndex
  127. } else {
  128. pendingRoute = MenuRoute(item)
  129. }
  130. }
  131. // MARK: - Search
  132. /// External Support & Community links, shared by the menu and search so their
  133. /// titles live in one place.
  134. private static let supportLinks: [(title: String, icon: String, url: URL)] = [
  135. ("LoopFollow Docs", "book", URL(string: "https://loopfollowdocs.org/")!),
  136. ("Loop and Learn Discord", "bubble.left.and.bubble.right", URL(string: "https://discord.gg/KQgk3gzuYU")!),
  137. ("LoopFollow Facebook Group", "person.2.fill", URL(string: "https://www.facebook.com/groups/loopfollowlnl")!),
  138. ]
  139. /// The searchable universe, assembled from the same sources that render the
  140. /// menu — the Settings routes, the tab features, and the static rows — so it
  141. /// never needs to be kept in sync by hand.
  142. private var searchItems: [MenuSearchItem] {
  143. var items: [MenuSearchItem] = [
  144. MenuSearchItem(title: "Settings", icon: "gearshape", keywords: [], kind: .settings(.settings)),
  145. ]
  146. let settingsSections = SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty)
  147. for (_, routes) in settingsSections {
  148. for route in routes {
  149. items.append(MenuSearchItem(title: route.title, icon: route.icon, keywords: route.keywords, kind: .settings(route)))
  150. }
  151. }
  152. for item in TabItem.featureOrder {
  153. items.append(MenuSearchItem(title: item.displayName, icon: item.icon, keywords: [], kind: .feature(item)))
  154. }
  155. items.append(MenuSearchItem(title: "View Log", icon: "doc.text.magnifyingglass", keywords: ["logging"], kind: .viewLog))
  156. items.append(MenuSearchItem(title: "Share Logs", icon: "square.and.arrow.up", keywords: ["logging"], kind: .shareLogs))
  157. for link in MoreMenuView.supportLinks {
  158. items.append(MenuSearchItem(title: link.title, icon: link.icon, keywords: ["support", "community"], kind: .link(link.url)))
  159. }
  160. // Bottom-level settings inside each sub-screen. A hit opens the screen
  161. // that contains the setting. Appended last so screen- and feature-level
  162. // matches rank above individual settings.
  163. for (_, routes) in settingsSections {
  164. for route in routes {
  165. for leaf in route.leaves {
  166. items.append(MenuSearchItem(
  167. title: leaf.title,
  168. icon: route.icon,
  169. keywords: leaf.keywords,
  170. kind: .settings(route),
  171. subtitle: "Settings → \(route.title)"
  172. ))
  173. }
  174. }
  175. }
  176. return items
  177. }
  178. private var filteredSearchItems: [MenuSearchItem] {
  179. searchItems.filter(matches)
  180. }
  181. private func matches(_ item: MenuSearchItem) -> Bool {
  182. let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
  183. guard !query.isEmpty else { return true }
  184. return item.title.localizedCaseInsensitiveContains(query)
  185. || item.keywords.contains { $0.localizedCaseInsensitiveContains(query) }
  186. }
  187. @ViewBuilder
  188. private var searchResultsSection: some View {
  189. Section {
  190. ForEach(filteredSearchItems) { item in
  191. searchRow(for: item)
  192. }
  193. }
  194. }
  195. @ViewBuilder
  196. private func searchRow(for item: MenuSearchItem) -> some View {
  197. switch item.kind {
  198. case let .settings(route):
  199. NavigationRow(title: item.title, subtitle: item.subtitle, icon: item.icon, value: route)
  200. case let .feature(feature):
  201. FullRowButton(showsChevron: true) { selectFeature(feature) } label: {
  202. Label(item.title, systemImage: item.icon)
  203. }
  204. case .viewLog:
  205. FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
  206. Label(item.title, systemImage: item.icon)
  207. }
  208. case .shareLogs:
  209. FullRowButton { shareLogs() } label: {
  210. Label(item.title, systemImage: item.icon)
  211. }
  212. case let .link(url):
  213. Link(destination: url) {
  214. HStack {
  215. Label(item.title, systemImage: item.icon)
  216. Spacer()
  217. Image(systemName: "arrow.up.right.square")
  218. .foregroundStyle(.tertiary)
  219. }
  220. }
  221. }
  222. }
  223. // MARK: - Helpers
  224. private func buildInfoRow(title: String, value: String, color: Color) -> some View {
  225. HStack {
  226. Text(title)
  227. Spacer()
  228. Text(value)
  229. .foregroundStyle(color)
  230. }
  231. }
  232. private func shareLogs() {
  233. let files = LogManager.shared.logFilesForTodayAndYesterday()
  234. guard !files.isEmpty else {
  235. alertTitle = String(localized: "No Logs Available")
  236. alertMessage = String(localized: "There are no logs to share.")
  237. showAlert = true
  238. return
  239. }
  240. let noticeView = ShareLogNoticeView(
  241. onCancel: {
  242. UIApplication.shared.topMost?.dismiss(animated: true)
  243. },
  244. onShare: { noticeText in
  245. let presenter = UIApplication.shared.topMost
  246. presenter?.dismiss(animated: true) {
  247. presentLogShareSheet(noticeText: noticeText, logFiles: files)
  248. }
  249. }
  250. )
  251. let host = UIHostingController(rootView: noticeView)
  252. host.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
  253. host.modalPresentationStyle = .formSheet
  254. UIApplication.shared.topMost?.present(host, animated: true)
  255. }
  256. private func presentLogShareSheet(noticeText: String, logFiles: [URL]) {
  257. var items: [Any] = logFiles
  258. if let noticeURL = writeShareNoticeFile(text: noticeText) {
  259. items.insert(noticeURL, at: 0)
  260. }
  261. let avc = UIActivityViewController(activityItems: items, applicationActivities: nil)
  262. UIApplication.shared.topMost?.present(avc, animated: true)
  263. }
  264. private func writeShareNoticeFile(text: String) -> URL? {
  265. let formatter = DateFormatter()
  266. formatter.dateFormat = "yyyy-MM-dd_HHmm"
  267. let timestamp = formatter.string(from: Date())
  268. let version = AppVersionManager().version()
  269. let branchAndSha = BuildDetails.default.branchAndSha
  270. let body = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
  271. ? "(no description provided)"
  272. : text
  273. let contents = """
  274. LoopFollow Log Share Notice
  275. Date: \(ISO8601DateFormatter().string(from: Date()))
  276. App version: \(version) (\(branchAndSha))
  277. User description:
  278. \(body)
  279. """
  280. let url = FileManager.default.temporaryDirectory
  281. .appendingPathComponent("ShareNotice_\(timestamp).txt")
  282. do {
  283. try contents.write(to: url, atomically: true, encoding: .utf8)
  284. return url
  285. } catch {
  286. LogManager.shared.log(category: .general, message: "Failed to write share notice file: \(error)")
  287. return nil
  288. }
  289. }
  290. private func fetchVersionInfo() async {
  291. let mgr = AppVersionManager()
  292. let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync()
  293. latestVersion = latest ?? "Unknown"
  294. versionTint = blacklisted ? .red
  295. : newer ? .orange
  296. : latest == currentVersion ? .green
  297. : .secondary
  298. }
  299. }
  300. // MARK: – Search model
  301. /// A single searchable menu entry. Assembled from the menu's existing data
  302. /// sources (see `MoreMenuView.searchItems`); `kind` carries how tapping it
  303. /// should behave so results reuse the same navigation as the live rows.
  304. private struct MenuSearchItem: Identifiable {
  305. let title: String
  306. let icon: String
  307. let keywords: [String]
  308. let kind: Kind
  309. var subtitle: String? = nil
  310. enum Kind {
  311. case settings(SettingsRoute)
  312. case feature(TabItem)
  313. case viewLog
  314. case shareLogs
  315. case link(URL)
  316. }
  317. var id: String {
  318. // Leaf settings share their screen's route, so the title is part of
  319. // the identity.
  320. switch kind {
  321. case let .settings(route): return "settings.\(route).\(title)"
  322. case let .feature(item): return "feature.\(item.rawValue)"
  323. case .viewLog: return "viewLog"
  324. case .shareLogs: return "shareLogs"
  325. case let .link(url): return "link.\(url.absoluteString)"
  326. }
  327. }
  328. }
  329. // MARK: – Full-row tappable button
  330. private struct FullRowButton<Label: View>: View {
  331. var showsChevron: Bool = false
  332. let action: () -> Void
  333. @ViewBuilder let label: () -> Label
  334. var body: some View {
  335. Button(action: action) {
  336. HStack {
  337. label()
  338. Spacer(minLength: 0)
  339. if showsChevron {
  340. Image(systemName: "chevron.right")
  341. .font(.footnote.weight(.semibold))
  342. .foregroundStyle(.tertiary)
  343. }
  344. }
  345. .contentShape(Rectangle())
  346. }
  347. .buttonStyle(.plain)
  348. }
  349. }
  350. // MARK: – Menu routing
  351. enum MenuRoute: Hashable {
  352. case home
  353. case alarms
  354. case remote
  355. case nightscout
  356. case snoozer
  357. case treatments
  358. case stats
  359. case log
  360. init(_ item: TabItem) {
  361. switch item {
  362. case .home: self = .home
  363. case .alarms: self = .alarms
  364. case .remote: self = .remote
  365. case .nightscout: self = .nightscout
  366. case .snoozer: self = .snoozer
  367. case .treatments: self = .treatments
  368. case .stats: self = .stats
  369. }
  370. }
  371. @ViewBuilder
  372. var destination: some View {
  373. switch self {
  374. case .home: HomeContentView(isModal: true)
  375. case .alarms: AlarmsContainerView(embedsInNavigationStack: false)
  376. case .remote: RemoteContentView()
  377. case .nightscout: NightscoutContentView()
  378. case .snoozer: SnoozerView()
  379. case .treatments: TreatmentsView()
  380. case .stats: AggregatedStatsContentView(mainViewController: MainViewController.shared)
  381. case .log: LogView()
  382. }
  383. }
  384. }