MoreMenuView.swift 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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. var body: some View {
  14. List {
  15. // Settings
  16. Section {
  17. NavigationLink(value: SettingsRoute.settings) {
  18. Label("Settings", systemImage: "gearshape")
  19. }
  20. }
  21. // Features
  22. Section("Features") {
  23. ForEach(TabItem.featureOrder) { item in
  24. FullRowButton(showsChevron: true) {
  25. let tabs = Storage.shared.orderedTabBarItems()
  26. if let tabIndex = tabs.firstIndex(of: item) {
  27. Observable.shared.selectedTabIndex.value = tabIndex
  28. } else {
  29. pendingRoute = MenuRoute(item)
  30. }
  31. } label: {
  32. Label(item.displayName, systemImage: item.icon)
  33. }
  34. }
  35. }
  36. // Logging
  37. Section("Logging") {
  38. FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
  39. Label("View Log", systemImage: "doc.text.magnifyingglass")
  40. }
  41. FullRowButton { shareLogs() } label: {
  42. Label("Share Logs", systemImage: "square.and.arrow.up")
  43. }
  44. }
  45. // Support & Community
  46. Section("Support & Community") {
  47. Link(destination: URL(string: "https://loopfollowdocs.org/")!) {
  48. HStack {
  49. Label("LoopFollow Docs", systemImage: "book")
  50. Spacer()
  51. Image(systemName: "arrow.up.right.square")
  52. .foregroundStyle(.tertiary)
  53. }
  54. }
  55. Link(destination: URL(string: "https://discord.gg/KQgk3gzuYU")!) {
  56. HStack {
  57. Label("Loop and Learn Discord", systemImage: "bubble.left.and.bubble.right")
  58. Spacer()
  59. Image(systemName: "arrow.up.right.square")
  60. .foregroundStyle(.tertiary)
  61. }
  62. }
  63. Link(destination: URL(string: "https://www.facebook.com/groups/loopfollowlnl")!) {
  64. HStack {
  65. Label("LoopFollow Facebook Group", systemImage: "person.2.fill")
  66. Spacer()
  67. Image(systemName: "arrow.up.right.square")
  68. .foregroundStyle(.tertiary)
  69. }
  70. }
  71. }
  72. // Build Information
  73. Section("Build Information") {
  74. buildInfoRow(title: "Version", value: currentVersion, color: versionTint)
  75. buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary)
  76. let build = BuildDetails.default
  77. if !(build.isMacApp() || build.isSimulatorBuild()) {
  78. buildInfoRow(
  79. title: build.expirationHeaderString,
  80. value: dateTimeUtils.formattedDate(from: build.calculateExpirationDate()),
  81. color: .secondary
  82. )
  83. }
  84. buildInfoRow(title: "Built", value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary)
  85. buildInfoRow(title: "Branch", value: build.branchAndSha, color: .secondary)
  86. }
  87. }
  88. .listStyle(.insetGrouped)
  89. .navigationTitle("Menu")
  90. .navigationBarTitleDisplayMode(.large)
  91. .task {
  92. await fetchVersionInfo()
  93. }
  94. .alert(alertTitle, isPresented: $showAlert) {
  95. Button("OK", role: .cancel) {}
  96. } message: {
  97. Text(alertMessage)
  98. }
  99. .navigationDestination(for: SettingsRoute.self) { $0.destination }
  100. .navigationDestination(
  101. isPresented: Binding(
  102. get: { pendingRoute != nil },
  103. set: { if !$0 { pendingRoute = nil } }
  104. )
  105. ) {
  106. if let route = pendingRoute {
  107. route.destination
  108. }
  109. }
  110. }
  111. // MARK: - Helpers
  112. private func buildInfoRow(title: String, value: String, color: Color) -> some View {
  113. HStack {
  114. Text(title)
  115. Spacer()
  116. Text(value)
  117. .foregroundStyle(color)
  118. }
  119. }
  120. private func shareLogs() {
  121. let files = LogManager.shared.logFilesForTodayAndYesterday()
  122. guard !files.isEmpty else {
  123. alertTitle = "No Logs Available"
  124. alertMessage = "There are no logs to share."
  125. showAlert = true
  126. return
  127. }
  128. let noticeView = ShareLogNoticeView(
  129. onCancel: {
  130. UIApplication.shared.topMost?.dismiss(animated: true)
  131. },
  132. onShare: { noticeText in
  133. let presenter = UIApplication.shared.topMost
  134. presenter?.dismiss(animated: true) {
  135. presentLogShareSheet(noticeText: noticeText, logFiles: files)
  136. }
  137. }
  138. )
  139. let host = UIHostingController(rootView: noticeView)
  140. host.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
  141. host.modalPresentationStyle = .formSheet
  142. UIApplication.shared.topMost?.present(host, animated: true)
  143. }
  144. private func presentLogShareSheet(noticeText: String, logFiles: [URL]) {
  145. var items: [Any] = logFiles
  146. if let noticeURL = writeShareNoticeFile(text: noticeText) {
  147. items.insert(noticeURL, at: 0)
  148. }
  149. let avc = UIActivityViewController(activityItems: items, applicationActivities: nil)
  150. UIApplication.shared.topMost?.present(avc, animated: true)
  151. }
  152. private func writeShareNoticeFile(text: String) -> URL? {
  153. let formatter = DateFormatter()
  154. formatter.dateFormat = "yyyy-MM-dd_HHmm"
  155. let timestamp = formatter.string(from: Date())
  156. let version = AppVersionManager().version()
  157. let branchAndSha = BuildDetails.default.branchAndSha
  158. let body = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
  159. ? "(no description provided)"
  160. : text
  161. let contents = """
  162. LoopFollow Log Share Notice
  163. Date: \(ISO8601DateFormatter().string(from: Date()))
  164. App version: \(version) (\(branchAndSha))
  165. User description:
  166. \(body)
  167. """
  168. let url = FileManager.default.temporaryDirectory
  169. .appendingPathComponent("ShareNotice_\(timestamp).txt")
  170. do {
  171. try contents.write(to: url, atomically: true, encoding: .utf8)
  172. return url
  173. } catch {
  174. LogManager.shared.log(category: .general, message: "Failed to write share notice file: \(error)")
  175. return nil
  176. }
  177. }
  178. private func fetchVersionInfo() async {
  179. let mgr = AppVersionManager()
  180. let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync()
  181. latestVersion = latest ?? "Unknown"
  182. versionTint = blacklisted ? .red
  183. : newer ? .orange
  184. : latest == currentVersion ? .green
  185. : .secondary
  186. }
  187. }
  188. // MARK: – Full-row tappable button
  189. private struct FullRowButton<Label: View>: View {
  190. var showsChevron: Bool = false
  191. let action: () -> Void
  192. @ViewBuilder let label: () -> Label
  193. var body: some View {
  194. Button(action: action) {
  195. HStack {
  196. label()
  197. Spacer(minLength: 0)
  198. if showsChevron {
  199. Image(systemName: "chevron.right")
  200. .font(.footnote.weight(.semibold))
  201. .foregroundStyle(.tertiary)
  202. }
  203. }
  204. .contentShape(Rectangle())
  205. }
  206. .buttonStyle(.plain)
  207. }
  208. }
  209. // MARK: – Menu routing
  210. enum MenuRoute: Hashable {
  211. case home
  212. case alarms
  213. case remote
  214. case nightscout
  215. case snoozer
  216. case treatments
  217. case stats
  218. case log
  219. init(_ item: TabItem) {
  220. switch item {
  221. case .home: self = .home
  222. case .alarms: self = .alarms
  223. case .remote: self = .remote
  224. case .nightscout: self = .nightscout
  225. case .snoozer: self = .snoozer
  226. case .treatments: self = .treatments
  227. case .stats: self = .stats
  228. }
  229. }
  230. @ViewBuilder
  231. var destination: some View {
  232. switch self {
  233. case .home: HomeContentView(isModal: true)
  234. case .alarms: AlarmsContainerView(embedsInNavigationStack: false)
  235. case .remote: RemoteContentView()
  236. case .nightscout: NightscoutContentView()
  237. case .snoozer: SnoozerView()
  238. case .treatments: TreatmentsView()
  239. case .stats: AggregatedStatsContentView(mainViewController: MainViewController.shared)
  240. case .log: LogView()
  241. }
  242. }
  243. }