|
|
@@ -10,21 +10,41 @@ class MoreMenuViewController: UIViewController {
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
private var fallbackMainViewController: MainViewController?
|
|
|
|
|
|
+ // Build Information state
|
|
|
+ private var latestVersion: String?
|
|
|
+ private var versionTint: UIColor = .secondaryLabel
|
|
|
+
|
|
|
+ // MARK: - Menu models
|
|
|
+
|
|
|
+ enum MenuItemStyle {
|
|
|
+ case navigation
|
|
|
+ case action
|
|
|
+ case detail(String, UIColor)
|
|
|
+ case externalLink
|
|
|
+ }
|
|
|
+
|
|
|
struct MenuItem {
|
|
|
let title: String
|
|
|
let icon: String
|
|
|
- let subtitle: String?
|
|
|
+ let style: MenuItemStyle
|
|
|
let action: () -> Void
|
|
|
|
|
|
- init(title: String, icon: String, subtitle: String? = nil, action: @escaping () -> Void) {
|
|
|
+ init(title: String, icon: String, style: MenuItemStyle = .navigation, action: @escaping () -> Void = {}) {
|
|
|
self.title = title
|
|
|
self.icon = icon
|
|
|
- self.subtitle = subtitle
|
|
|
+ self.style = style
|
|
|
self.action = action
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private var menuSections: [[MenuItem]] = []
|
|
|
+ struct MenuSection {
|
|
|
+ let title: String?
|
|
|
+ let items: [MenuItem]
|
|
|
+ }
|
|
|
+
|
|
|
+ private var menuSections: [MenuSection] = []
|
|
|
+
|
|
|
+ // MARK: - Lifecycle
|
|
|
|
|
|
override func viewDidLoad() {
|
|
|
super.viewDidLoad()
|
|
|
@@ -53,6 +73,10 @@ class MoreMenuViewController: UIViewController {
|
|
|
|
|
|
setupTableView()
|
|
|
updateMenuItems()
|
|
|
+
|
|
|
+ Task { [weak self] in
|
|
|
+ await self?.fetchVersionInfo()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
|
@@ -72,6 +96,8 @@ class MoreMenuViewController: UIViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // MARK: - Setup
|
|
|
+
|
|
|
private func setupTableView() {
|
|
|
tableView = UITableView(frame: view.bounds, style: .insetGrouped)
|
|
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -89,51 +115,115 @@ class MoreMenuViewController: UIViewController {
|
|
|
])
|
|
|
}
|
|
|
|
|
|
+ // MARK: - Menu construction
|
|
|
+
|
|
|
private func updateMenuItems() {
|
|
|
- menuSections = []
|
|
|
-
|
|
|
- // Section 0: Settings (always fixed at top)
|
|
|
- let settingsSection = [
|
|
|
- MenuItem(
|
|
|
- title: "Settings",
|
|
|
- icon: "gear",
|
|
|
- action: { [weak self] in
|
|
|
+ let build = BuildDetails.default
|
|
|
+ let ver = AppVersionManager().version()
|
|
|
+
|
|
|
+ menuSections = [
|
|
|
+ // Section 0: Settings
|
|
|
+ MenuSection(title: nil, items: [
|
|
|
+ MenuItem(title: "Settings", icon: "gear") { [weak self] in
|
|
|
self?.openSettings()
|
|
|
+ },
|
|
|
+ ]),
|
|
|
+
|
|
|
+ // Section 1: All tab items (static)
|
|
|
+ MenuSection(title: nil, items: TabItem.allCases.map { item in
|
|
|
+ MenuItem(title: item.displayName, icon: item.icon) { [weak self] in
|
|
|
+ self?.openItem(item)
|
|
|
}
|
|
|
- ),
|
|
|
- ]
|
|
|
- menuSections.append(settingsSection)
|
|
|
-
|
|
|
- let itemsInMenu = Storage.shared.itemsInMenu()
|
|
|
-
|
|
|
- if !itemsInMenu.isEmpty {
|
|
|
- var dynamicSection: [MenuItem] = []
|
|
|
- for item in itemsInMenu {
|
|
|
- dynamicSection.append(MenuItem(
|
|
|
- title: item.displayName,
|
|
|
- icon: item.icon,
|
|
|
- action: { [weak self] in
|
|
|
- self?.openItem(item)
|
|
|
- }
|
|
|
+ }),
|
|
|
+
|
|
|
+ // Section 2: Logging
|
|
|
+ MenuSection(title: "Logging", items: [
|
|
|
+ MenuItem(title: "View Log", icon: "doc.text.magnifyingglass") { [weak self] in
|
|
|
+ self?.openViewLog()
|
|
|
+ },
|
|
|
+ MenuItem(title: "Share Logs", icon: "square.and.arrow.up", style: .action) { [weak self] in
|
|
|
+ self?.shareLogs()
|
|
|
+ },
|
|
|
+ ]),
|
|
|
+
|
|
|
+ // Section 3: Support & Community
|
|
|
+ MenuSection(title: "Support & Community", items: [
|
|
|
+ MenuItem(title: "LoopFollow Docs", icon: "book", style: .externalLink) { [weak self] in
|
|
|
+ self?.openURL("https://loopfollowdocs.org/")
|
|
|
+ },
|
|
|
+ MenuItem(title: "Loop and Learn Discord", icon: "bubble.left.and.bubble.right", style: .externalLink) { [weak self] in
|
|
|
+ self?.openURL("https://discord.gg/TKTuX3dA")
|
|
|
+ },
|
|
|
+ MenuItem(title: "LoopFollow Facebook Group", icon: "person.2.fill", style: .externalLink) { [weak self] in
|
|
|
+ self?.openURL("https://www.facebook.com/groups/loopfollowlnl")
|
|
|
+ },
|
|
|
+ ]),
|
|
|
+
|
|
|
+ // Section 4: Build Information
|
|
|
+ MenuSection(title: "Build Information", items: {
|
|
|
+ var items: [MenuItem] = [
|
|
|
+ MenuItem(title: "Version", icon: "", style: .detail(ver, versionTint)),
|
|
|
+ MenuItem(title: "Latest version", icon: "", style: .detail(latestVersion ?? "Fetching…", .secondaryLabel)),
|
|
|
+ ]
|
|
|
+
|
|
|
+ if !(build.isMacApp() || build.isSimulatorBuild()) {
|
|
|
+ items.append(MenuItem(
|
|
|
+ title: build.expirationHeaderString,
|
|
|
+ icon: "",
|
|
|
+ style: .detail(dateTimeUtils.formattedDate(from: build.calculateExpirationDate()), .secondaryLabel)
|
|
|
+ ))
|
|
|
+ }
|
|
|
+
|
|
|
+ items.append(MenuItem(
|
|
|
+ title: "Built",
|
|
|
+ icon: "",
|
|
|
+ style: .detail(dateTimeUtils.formattedDate(from: build.buildDate()), .secondaryLabel)
|
|
|
+ ))
|
|
|
+ items.append(MenuItem(
|
|
|
+ title: "Branch",
|
|
|
+ icon: "",
|
|
|
+ style: .detail(build.branchAndSha, .secondaryLabel)
|
|
|
))
|
|
|
- }
|
|
|
- menuSections.append(dynamicSection)
|
|
|
- }
|
|
|
|
|
|
- // Section: Community
|
|
|
- let communitySection = [
|
|
|
- MenuItem(
|
|
|
- title: "LoopFollow Facebook Group",
|
|
|
- icon: "person.2.fill",
|
|
|
- action: { [weak self] in
|
|
|
- self?.openFacebookGroup()
|
|
|
- }
|
|
|
- ),
|
|
|
+ return items
|
|
|
+ }()),
|
|
|
]
|
|
|
- menuSections.append(communitySection)
|
|
|
}
|
|
|
|
|
|
+ // MARK: - Version fetching
|
|
|
+
|
|
|
+ private func fetchVersionInfo() async {
|
|
|
+ let mgr = AppVersionManager()
|
|
|
+ let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync()
|
|
|
+ latestVersion = latest ?? "Unknown"
|
|
|
+
|
|
|
+ let current = mgr.version()
|
|
|
+ versionTint = blacklisted ? .systemRed
|
|
|
+ : newer ? .systemOrange
|
|
|
+ : latest == current ? .systemGreen
|
|
|
+ : .secondaryLabel
|
|
|
+
|
|
|
+ await MainActor.run {
|
|
|
+ updateMenuItems()
|
|
|
+ tableView.reloadData()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Navigation
|
|
|
+
|
|
|
private func openItem(_ item: TabItem) {
|
|
|
+ // If the item is in the tab bar, switch to it
|
|
|
+ if let tabVC = tabBarController,
|
|
|
+ let index = (tabVC.viewControllers ?? []).firstIndex(where: { $0.tabBarItem.title == item.displayName })
|
|
|
+ {
|
|
|
+ tabVC.selectedIndex = index
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // Otherwise present as modal
|
|
|
+ presentItemAsModal(item)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func presentItemAsModal(_ item: TabItem) {
|
|
|
switch item {
|
|
|
case .home:
|
|
|
openHome()
|
|
|
@@ -260,12 +350,10 @@ class MoreMenuViewController: UIViewController {
|
|
|
let treatmentsVC = UIHostingController(rootView: TreatmentsView())
|
|
|
let navController = UINavigationController(rootViewController: treatmentsVC)
|
|
|
|
|
|
- // Apply appearance mode
|
|
|
let style = Storage.shared.appearanceMode.value.userInterfaceStyle
|
|
|
treatmentsVC.overrideUserInterfaceStyle = style
|
|
|
navController.overrideUserInterfaceStyle = style
|
|
|
|
|
|
- // Add a close button
|
|
|
treatmentsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
|
image: UIImage(systemName: "checkmark"),
|
|
|
style: .plain,
|
|
|
@@ -289,12 +377,10 @@ class MoreMenuViewController: UIViewController {
|
|
|
)
|
|
|
let navController = UINavigationController(rootViewController: statsVC)
|
|
|
|
|
|
- // Apply appearance mode
|
|
|
let style = Storage.shared.appearanceMode.value.userInterfaceStyle
|
|
|
statsVC.overrideUserInterfaceStyle = style
|
|
|
navController.overrideUserInterfaceStyle = style
|
|
|
|
|
|
- // Add a close button
|
|
|
statsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
|
image: UIImage(systemName: "checkmark"),
|
|
|
style: .plain,
|
|
|
@@ -308,33 +394,52 @@ class MoreMenuViewController: UIViewController {
|
|
|
}
|
|
|
|
|
|
private func openHome() {
|
|
|
- // First check if Home is in the tab bar
|
|
|
- if let tabVC = tabBarController {
|
|
|
- for (index, vc) in (tabVC.viewControllers ?? []).enumerated() {
|
|
|
- if vc is MainViewController {
|
|
|
- // Home is in the tab bar, switch to it
|
|
|
- tabVC.selectedIndex = index
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Home is in the menu - present the full Home screen as a modal
|
|
|
let homeModalView = HomeModalView()
|
|
|
let hostingController = UIHostingController(rootView: homeModalView)
|
|
|
-
|
|
|
hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
|
|
|
-
|
|
|
hostingController.modalPresentationStyle = .fullScreen
|
|
|
present(hostingController, animated: true)
|
|
|
}
|
|
|
|
|
|
- private func openFacebookGroup() {
|
|
|
- if let url = URL(string: "https://www.facebook.com/groups/loopfollowlnl") {
|
|
|
+ private func openViewLog() {
|
|
|
+ let logVC = UIHostingController(rootView: LogView())
|
|
|
+ logVC.title = "Log"
|
|
|
+ let navController = UINavigationController(rootViewController: logVC)
|
|
|
+
|
|
|
+ let style = Storage.shared.appearanceMode.value.userInterfaceStyle
|
|
|
+ logVC.overrideUserInterfaceStyle = style
|
|
|
+ navController.overrideUserInterfaceStyle = style
|
|
|
+
|
|
|
+ logVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
|
+ image: UIImage(systemName: "checkmark"),
|
|
|
+ style: .plain,
|
|
|
+ target: self,
|
|
|
+ action: #selector(dismissModal)
|
|
|
+ )
|
|
|
+ logVC.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
|
|
|
+
|
|
|
+ navController.modalPresentationStyle = .fullScreen
|
|
|
+ present(navController, animated: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func shareLogs() {
|
|
|
+ let files = LogManager.shared.logFilesForTodayAndYesterday()
|
|
|
+ guard !files.isEmpty else {
|
|
|
+ presentSimpleAlert(title: "No Logs Available", message: "There are no logs to share.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ let avc = UIActivityViewController(activityItems: files, applicationActivities: nil)
|
|
|
+ present(avc, animated: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func openURL(_ urlString: String) {
|
|
|
+ if let url = URL(string: urlString) {
|
|
|
UIApplication.shared.open(url)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // MARK: - Helpers
|
|
|
+
|
|
|
@objc private func dismissSettingsModal() {
|
|
|
dismiss(animated: true) {
|
|
|
// Rebuild tabs after settings is dismissed to apply any tab order changes
|
|
|
@@ -343,7 +448,6 @@ class MoreMenuViewController: UIViewController {
|
|
|
}
|
|
|
|
|
|
private func getMainViewController() -> MainViewController? {
|
|
|
- // Try to find MainViewController in the view hierarchy
|
|
|
guard let tabBarController = tabBarController else { return nil }
|
|
|
|
|
|
for vc in tabBarController.viewControllers ?? [] {
|
|
|
@@ -376,37 +480,71 @@ class MoreMenuViewController: UIViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// MARK: - UITableViewDataSource & UITableViewDelegate
|
|
|
+
|
|
|
extension MoreMenuViewController: UITableViewDataSource, UITableViewDelegate {
|
|
|
func numberOfSections(in _: UITableView) -> Int {
|
|
|
return menuSections.count
|
|
|
}
|
|
|
|
|
|
func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
|
- return menuSections[section].count
|
|
|
+ return menuSections[section].items.count
|
|
|
+ }
|
|
|
+
|
|
|
+ func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? {
|
|
|
+ return menuSections[section].title
|
|
|
}
|
|
|
|
|
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
|
|
|
- let item = menuSections[indexPath.section][indexPath.row]
|
|
|
-
|
|
|
- var config = cell.defaultContentConfiguration()
|
|
|
- config.text = item.title
|
|
|
- config.image = UIImage(systemName: item.icon)
|
|
|
-
|
|
|
- if let subtitle = item.subtitle {
|
|
|
- config.secondaryText = subtitle
|
|
|
- config.secondaryTextProperties.color = .orange
|
|
|
- config.secondaryTextProperties.font = .preferredFont(forTextStyle: .caption1)
|
|
|
+ let item = menuSections[indexPath.section].items[indexPath.row]
|
|
|
+
|
|
|
+ switch item.style {
|
|
|
+ case let .detail(value, color):
|
|
|
+ var config = UIListContentConfiguration.valueCell()
|
|
|
+ config.text = item.title
|
|
|
+ config.secondaryText = value
|
|
|
+ config.secondaryTextProperties.color = color
|
|
|
+ cell.contentConfiguration = config
|
|
|
+ cell.accessoryType = .none
|
|
|
+ cell.selectionStyle = .none
|
|
|
+
|
|
|
+ case .externalLink:
|
|
|
+ var config = cell.defaultContentConfiguration()
|
|
|
+ config.text = item.title
|
|
|
+ config.image = UIImage(systemName: item.icon)
|
|
|
+ cell.contentConfiguration = config
|
|
|
+ let linkImage = UIImageView(image: UIImage(systemName: "arrow.up.right.square"))
|
|
|
+ linkImage.tintColor = .tertiaryLabel
|
|
|
+ cell.accessoryView = linkImage
|
|
|
+ cell.selectionStyle = .default
|
|
|
+
|
|
|
+ case .navigation:
|
|
|
+ var config = cell.defaultContentConfiguration()
|
|
|
+ config.text = item.title
|
|
|
+ config.image = UIImage(systemName: item.icon)
|
|
|
+ cell.contentConfiguration = config
|
|
|
+ cell.accessoryView = nil
|
|
|
+ cell.accessoryType = .disclosureIndicator
|
|
|
+ cell.selectionStyle = .default
|
|
|
+
|
|
|
+ case .action:
|
|
|
+ var config = cell.defaultContentConfiguration()
|
|
|
+ config.text = item.title
|
|
|
+ config.image = UIImage(systemName: item.icon)
|
|
|
+ cell.contentConfiguration = config
|
|
|
+ cell.accessoryView = nil
|
|
|
+ cell.accessoryType = .none
|
|
|
+ cell.selectionStyle = .default
|
|
|
}
|
|
|
|
|
|
- cell.contentConfiguration = config
|
|
|
- cell.accessoryType = .disclosureIndicator
|
|
|
-
|
|
|
return cell
|
|
|
}
|
|
|
|
|
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
|
- menuSections[indexPath.section][indexPath.row].action()
|
|
|
+ let item = menuSections[indexPath.section].items[indexPath.row]
|
|
|
+ if case .detail = item.style { return }
|
|
|
+ item.action()
|
|
|
}
|
|
|
}
|