| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- // LoopFollow
- // MoreMenuView.swift
- import SwiftUI
- import UIKit
- struct MoreMenuView: View {
- @State private var pendingRoute: MenuRoute?
- @State private var latestVersion: String?
- @State private var versionTint: Color = .secondary
- @State private var alertTitle = ""
- @State private var alertMessage = ""
- @State private var showAlert = false
- @State private var currentVersion: String = AppVersionManager().version()
- @State private var searchText = ""
- @ObservedObject private var nightscoutURL = Storage.shared.url
- private var isSearching: Bool {
- !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- }
- var body: some View {
- List {
- if isSearching {
- searchResultsSection
- } else {
- menuContent
- }
- }
- .listStyle(.insetGrouped)
- .navigationTitle("Menu")
- .navigationBarTitleDisplayMode(.large)
- .searchable(text: $searchText, prompt: "Search")
- .overlay {
- if isSearching, filteredSearchItems.isEmpty {
- VStack(spacing: 8) {
- Image(systemName: "magnifyingglass")
- .font(.largeTitle)
- .foregroundColor(.secondary)
- Text("No Results")
- .font(.headline)
- Text("No matches for “\(searchText)”.")
- .font(.subheadline)
- .foregroundColor(.secondary)
- .multilineTextAlignment(.center)
- }
- .padding(.horizontal)
- }
- }
- .task {
- await fetchVersionInfo()
- }
- .alert(alertTitle, isPresented: $showAlert) {
- Button("OK", role: .cancel) {}
- } message: {
- Text(alertMessage)
- }
- .navigationDestination(for: SettingsRoute.self) { $0.destination }
- .navigationDestination(
- isPresented: Binding(
- get: { pendingRoute != nil },
- set: { if !$0 { pendingRoute = nil } }
- )
- ) {
- if let route = pendingRoute {
- route.destination
- }
- }
- }
- // MARK: - Menu content
- @ViewBuilder
- private var menuContent: some View {
- // Settings
- Section {
- NavigationLink(value: SettingsRoute.settings) {
- Label("Settings", systemImage: "gearshape")
- }
- }
- // Features
- Section("Features") {
- ForEach(TabItem.featureOrder) { item in
- FullRowButton(showsChevron: true) {
- selectFeature(item)
- } label: {
- Label(item.displayName, systemImage: item.icon)
- }
- }
- }
- // Logging
- Section("Logging") {
- FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
- Label("View Log", systemImage: "doc.text.magnifyingglass")
- }
- FullRowButton { shareLogs() } label: {
- Label("Share Logs", systemImage: "square.and.arrow.up")
- }
- }
- // Support & Community
- Section("Support & Community") {
- ForEach(MoreMenuView.supportLinks, id: \.url) { link in
- Link(destination: link.url) {
- HStack {
- Label(link.title, systemImage: link.icon)
- Spacer()
- Image(systemName: "arrow.up.right.square")
- .foregroundStyle(.tertiary)
- }
- }
- }
- }
- // Build Information
- Section("Build Information") {
- buildInfoRow(title: "Version", value: currentVersion, color: versionTint)
- buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary)
- let build = BuildDetails.default
- if !(build.isMacApp() || build.isSimulatorBuild()) {
- buildInfoRow(
- title: build.expirationHeaderString,
- value: dateTimeUtils.formattedDate(from: build.calculateExpirationDate()),
- color: .secondary
- )
- }
- buildInfoRow(title: "Built", value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary)
- buildInfoRow(title: "Branch", value: build.branchAndSha, color: .secondary)
- }
- }
- private func selectFeature(_ item: TabItem) {
- let tabs = Storage.shared.orderedTabBarItems()
- if let tabIndex = tabs.firstIndex(of: item) {
- Observable.shared.selectedTabIndex.value = tabIndex
- } else {
- pendingRoute = MenuRoute(item)
- }
- }
- // MARK: - Search
- /// External Support & Community links, shared by the menu and search so their
- /// titles live in one place.
- private static let supportLinks: [(title: String, icon: String, url: URL)] = [
- ("LoopFollow Docs", "book", URL(string: "https://loopfollowdocs.org/")!),
- ("Loop and Learn Discord", "bubble.left.and.bubble.right", URL(string: "https://discord.gg/KQgk3gzuYU")!),
- ("LoopFollow Facebook Group", "person.2.fill", URL(string: "https://www.facebook.com/groups/loopfollowlnl")!),
- ]
- /// The searchable universe, assembled from the same sources that render the
- /// menu — the Settings routes, the tab features, and the static rows — so it
- /// never needs to be kept in sync by hand.
- private var searchItems: [MenuSearchItem] {
- var items: [MenuSearchItem] = [
- MenuSearchItem(title: "Settings", icon: "gearshape", keywords: [], kind: .settings(.settings)),
- ]
- let settingsSections = SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty)
- for (_, routes) in settingsSections {
- for route in routes {
- items.append(MenuSearchItem(title: route.title, icon: route.icon, keywords: route.keywords, kind: .settings(route)))
- }
- }
- for item in TabItem.featureOrder {
- items.append(MenuSearchItem(title: item.displayName, icon: item.icon, keywords: [], kind: .feature(item)))
- }
- items.append(MenuSearchItem(title: "View Log", icon: "doc.text.magnifyingglass", keywords: ["logging"], kind: .viewLog))
- items.append(MenuSearchItem(title: "Share Logs", icon: "square.and.arrow.up", keywords: ["logging"], kind: .shareLogs))
- for link in MoreMenuView.supportLinks {
- items.append(MenuSearchItem(title: link.title, icon: link.icon, keywords: ["support", "community"], kind: .link(link.url)))
- }
- // Bottom-level settings inside each sub-screen. A hit opens the screen
- // that contains the setting. Appended last so screen- and feature-level
- // matches rank above individual settings.
- for (_, routes) in settingsSections {
- for route in routes {
- for leaf in route.leaves {
- items.append(MenuSearchItem(
- title: leaf.title,
- icon: route.icon,
- keywords: leaf.keywords,
- kind: .settings(route),
- subtitle: "Settings → \(route.title)"
- ))
- }
- }
- }
- return items
- }
- private var filteredSearchItems: [MenuSearchItem] {
- searchItems.filter(matches)
- }
- private func matches(_ item: MenuSearchItem) -> Bool {
- let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !query.isEmpty else { return true }
- return item.title.localizedCaseInsensitiveContains(query)
- || item.keywords.contains { $0.localizedCaseInsensitiveContains(query) }
- }
- @ViewBuilder
- private var searchResultsSection: some View {
- Section {
- ForEach(filteredSearchItems) { item in
- searchRow(for: item)
- }
- }
- }
- @ViewBuilder
- private func searchRow(for item: MenuSearchItem) -> some View {
- switch item.kind {
- case let .settings(route):
- NavigationRow(title: item.title, subtitle: item.subtitle, icon: item.icon, value: route)
- case let .feature(feature):
- FullRowButton(showsChevron: true) { selectFeature(feature) } label: {
- Label(item.title, systemImage: item.icon)
- }
- case .viewLog:
- FullRowButton(showsChevron: true) { pendingRoute = .log } label: {
- Label(item.title, systemImage: item.icon)
- }
- case .shareLogs:
- FullRowButton { shareLogs() } label: {
- Label(item.title, systemImage: item.icon)
- }
- case let .link(url):
- Link(destination: url) {
- HStack {
- Label(item.title, systemImage: item.icon)
- Spacer()
- Image(systemName: "arrow.up.right.square")
- .foregroundStyle(.tertiary)
- }
- }
- }
- }
- // MARK: - Helpers
- private func buildInfoRow(title: String, value: String, color: Color) -> some View {
- HStack {
- Text(title)
- Spacer()
- Text(value)
- .foregroundStyle(color)
- }
- }
- private func shareLogs() {
- let files = LogManager.shared.logFilesForTodayAndYesterday()
- guard !files.isEmpty else {
- alertTitle = String(localized: "No Logs Available")
- alertMessage = String(localized: "There are no logs to share.")
- showAlert = true
- return
- }
- let noticeView = ShareLogNoticeView(
- onCancel: {
- UIApplication.shared.topMost?.dismiss(animated: true)
- },
- onShare: { noticeText in
- let presenter = UIApplication.shared.topMost
- presenter?.dismiss(animated: true) {
- presentLogShareSheet(noticeText: noticeText, logFiles: files)
- }
- }
- )
- let host = UIHostingController(rootView: noticeView)
- host.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
- host.modalPresentationStyle = .formSheet
- UIApplication.shared.topMost?.present(host, animated: true)
- }
- private func presentLogShareSheet(noticeText: String, logFiles: [URL]) {
- var items: [Any] = logFiles
- if let noticeURL = writeShareNoticeFile(text: noticeText) {
- items.insert(noticeURL, at: 0)
- }
- let avc = UIActivityViewController(activityItems: items, applicationActivities: nil)
- UIApplication.shared.topMost?.present(avc, animated: true)
- }
- private func writeShareNoticeFile(text: String) -> URL? {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd_HHmm"
- let timestamp = formatter.string(from: Date())
- let version = AppVersionManager().version()
- let branchAndSha = BuildDetails.default.branchAndSha
- let body = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- ? "(no description provided)"
- : text
- let contents = """
- LoopFollow Log Share Notice
- Date: \(ISO8601DateFormatter().string(from: Date()))
- App version: \(version) (\(branchAndSha))
- User description:
- \(body)
- """
- let url = FileManager.default.temporaryDirectory
- .appendingPathComponent("ShareNotice_\(timestamp).txt")
- do {
- try contents.write(to: url, atomically: true, encoding: .utf8)
- return url
- } catch {
- LogManager.shared.log(category: .general, message: "Failed to write share notice file: \(error)")
- return nil
- }
- }
- private func fetchVersionInfo() async {
- let mgr = AppVersionManager()
- let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync()
- latestVersion = latest ?? "Unknown"
- versionTint = blacklisted ? .red
- : newer ? .orange
- : latest == currentVersion ? .green
- : .secondary
- }
- }
- // MARK: – Search model
- /// A single searchable menu entry. Assembled from the menu's existing data
- /// sources (see `MoreMenuView.searchItems`); `kind` carries how tapping it
- /// should behave so results reuse the same navigation as the live rows.
- private struct MenuSearchItem: Identifiable {
- let title: String
- let icon: String
- let keywords: [String]
- let kind: Kind
- var subtitle: String? = nil
- enum Kind {
- case settings(SettingsRoute)
- case feature(TabItem)
- case viewLog
- case shareLogs
- case link(URL)
- }
- var id: String {
- // Leaf settings share their screen's route, so the title is part of
- // the identity.
- switch kind {
- case let .settings(route): return "settings.\(route).\(title)"
- case let .feature(item): return "feature.\(item.rawValue)"
- case .viewLog: return "viewLog"
- case .shareLogs: return "shareLogs"
- case let .link(url): return "link.\(url.absoluteString)"
- }
- }
- }
- // MARK: – Full-row tappable button
- private struct FullRowButton<Label: View>: View {
- var showsChevron: Bool = false
- let action: () -> Void
- @ViewBuilder let label: () -> Label
- var body: some View {
- Button(action: action) {
- HStack {
- label()
- Spacer(minLength: 0)
- if showsChevron {
- Image(systemName: "chevron.right")
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.tertiary)
- }
- }
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- }
- }
- // MARK: – Menu routing
- enum MenuRoute: Hashable {
- case home
- case alarms
- case remote
- case nightscout
- case snoozer
- case treatments
- case stats
- case log
- init(_ item: TabItem) {
- switch item {
- case .home: self = .home
- case .alarms: self = .alarms
- case .remote: self = .remote
- case .nightscout: self = .nightscout
- case .snoozer: self = .snoozer
- case .treatments: self = .treatments
- case .stats: self = .stats
- }
- }
- @ViewBuilder
- var destination: some View {
- switch self {
- case .home: HomeContentView(isModal: true)
- case .alarms: AlarmsContainerView(embedsInNavigationStack: false)
- case .remote: RemoteContentView()
- case .nightscout: NightscoutContentView()
- case .snoozer: SnoozerView()
- case .treatments: TreatmentsView()
- case .stats: AggregatedStatsContentView(mainViewController: MainViewController.shared)
- case .log: LogView()
- }
- }
- }
|