From ebb8f90b23812f2096382b0e11a9a43a8f87e607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 8 Jul 2025 21:50:44 +0200 Subject: [PATCH] feat: add user-configurable tab customization for tabs 2 and 4 --- LoopFollow.xcodeproj/project.pbxproj | 8 ++ .../Application/Base.lproj/Main.storyboard | 2 +- LoopFollow/Helpers/TabSelection.swift | 33 +++++++ LoopFollow/Settings/SettingsMenuView.swift | 8 ++ .../TabCustomizationSettingsView.swift | 35 +++++++ LoopFollow/Storage/Storage+Migrate.swift | 13 +++ LoopFollow/Storage/Storage.swift | 3 + .../ViewControllers/MainViewController.swift | 93 ++++++++++--------- 8 files changed, 148 insertions(+), 47 deletions(-) create mode 100644 LoopFollow/Helpers/TabSelection.swift create mode 100644 LoopFollow/Settings/TabCustomizationSettingsView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 0087f389e..5ff2da4c3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -41,6 +41,8 @@ DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; DD1A97142D4294A5000DDC11 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */; }; DD1A97162D4294B3000DDC11 /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */; }; + DD1D52922E1D9E8200432050 /* TabSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52912E1D9E8200432050 /* TabSelection.swift */; }; + DD1D52942E1DA31000432050 /* TabCustomizationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52932E1DA31000432050 /* TabCustomizationSettingsView.swift */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; }; DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */; }; @@ -415,6 +417,8 @@ DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; DD1A97132D4294A4000DDC11 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; DD1A97152D4294B2000DDC11 /* AdvancedSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsViewModel.swift; sourceTree = ""; }; + DD1D52912E1D9E8200432050 /* TabSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSelection.swift; sourceTree = ""; }; + DD1D52932E1DA31000432050 /* TabCustomizationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationSettingsView.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = ""; }; DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; @@ -863,6 +867,7 @@ DD1A97122D429495000DDC11 /* Settings */ = { isa = PBXGroup; children = ( + DD1D52932E1DA31000432050 /* TabCustomizationSettingsView.swift */, DD83164F2DE4E635004467AA /* SettingsMenuView.swift */, DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */, DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */, @@ -1456,6 +1461,7 @@ FCC688542489367300A0279D /* Helpers */ = { isa = PBXGroup; children = ( + DD1D52912E1D9E8200432050 /* TabSelection.swift */, DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */, DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */, DD7B0D432D730A320063DCB6 /* CycleHelper.swift */, @@ -1825,6 +1831,7 @@ DD0B9D582DE1F3B20090C337 /* AlarmType+canAcknowledge.swift in Sources */, DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */, DD7F4BC52DD3CE0700D449E9 /* AlarmBGLimitSection.swift in Sources */, + DD1D52942E1DA31000432050 /* TabCustomizationSettingsView.swift in Sources */, DD7F4B9F2DD1F92700D449E9 /* AlarmActiveSection.swift in Sources */, DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */, DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, @@ -2019,6 +2026,7 @@ DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */, DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */, FC1BDD2B24A22650001B652C /* Stats.swift in Sources */, + DD1D52922E1D9E8200432050 /* TabSelection.swift in Sources */, DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */, DDDF6F432D479A9900884336 /* LoopNightscoutRemoteView.swift in Sources */, DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */, diff --git a/LoopFollow/Application/Base.lproj/Main.storyboard b/LoopFollow/Application/Base.lproj/Main.storyboard index 2278db6db..e05e80075 100644 --- a/LoopFollow/Application/Base.lproj/Main.storyboard +++ b/LoopFollow/Application/Base.lproj/Main.storyboard @@ -337,7 +337,7 @@ - + diff --git a/LoopFollow/Helpers/TabSelection.swift b/LoopFollow/Helpers/TabSelection.swift new file mode 100644 index 000000000..8ec504848 --- /dev/null +++ b/LoopFollow/Helpers/TabSelection.swift @@ -0,0 +1,33 @@ +// LoopFollow +// TabSelection.swift +// Created by Jonas Björkert. + +enum TabSelection: String, CaseIterable, Codable { + case alarms + case remote + case nightscout + + var displayName: String { + switch self { + case .alarms: return "Alarms" + case .remote: return "Remote" + case .nightscout: return "Nightscout" + } + } + + var systemImage: String { + switch self { + case .alarms: return "alarm" + case .remote: return "antenna.radiowaves.left.and.right" + case .nightscout: return "safari" + } + } + + var storyboardIdentifier: String { + switch self { + case .alarms: return "AlarmViewController" + case .remote: return "RemoteViewController" + case .nightscout: return "NightscoutViewController" + } + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index d8a52ccac..0dc15cd75 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -44,6 +44,12 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.graph) } + NavigationRow(title: "Tab Customization", + icon: "rectangle.3.group") + { + settingsPath.value.append(Sheet.tabCustomization) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Information Display Settings", icon: "info.circle") @@ -226,6 +232,7 @@ private enum Sheet: Hashable, Identifiable { case calendar, contact case advanced case viewLog + case tabCustomization var id: Self { self } @@ -245,6 +252,7 @@ private enum Sheet: Hashable, Identifiable { case .contact: ContactSettingsView(viewModel: .init()) case .advanced: AdvancedSettingsView(viewModel: .init()) case .viewLog: LogView(viewModel: .init()) + case .tabCustomization: TabCustomizationSettingsView() } } } diff --git a/LoopFollow/Settings/TabCustomizationSettingsView.swift b/LoopFollow/Settings/TabCustomizationSettingsView.swift new file mode 100644 index 000000000..245d1b2f5 --- /dev/null +++ b/LoopFollow/Settings/TabCustomizationSettingsView.swift @@ -0,0 +1,35 @@ +// LoopFollow +// TabCustomizationSettingsView.swift +// Created by Jonas Björkert. + +import SwiftUI + +struct TabCustomizationSettingsView: View { + @ObservedObject var tab2Selection = Storage.shared.tab2Selection + @ObservedObject var tab4Selection = Storage.shared.tab4Selection + + var body: some View { + Form { + Section("Tab Customization") { + Picker("Tab 2", selection: $tab2Selection.value) { + ForEach(TabSelection.allCases, id: \.self) { selection in + Text(selection.displayName).tag(selection) + } + } + + Picker("Tab 4", selection: $tab4Selection.value) { + ForEach(TabSelection.allCases, id: \.self) { selection in + Text(selection.displayName).tag(selection) + } + } + } + + Section { + Text("Note: Home (Tab 1), Snoozer (Tab 3), and Settings (Tab 5) cannot be changed") + .font(.caption) + .foregroundColor(.secondary) + } + } + .navigationBarTitle("Tab Settings", displayMode: .inline) + } +} diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index 5af3eb8ac..58ec6e78d 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -375,6 +375,19 @@ extension Storage { migrateRecBolusAlarm() } + func migrateStep2() { + // Migrate from remoteType-based tab selection to user-configurable tabs + if remoteType.value == .none { + // If remote is disabled, set tab 2 to Alarms + tab2Selection.value = .alarms + } else { + // If remote is enabled (any type), set tab 2 to Remote + tab2Selection.value = .remote + } + + // Tab 4 defaults to nightscout (already set by default value) + } + // MARK: - One-off alarm migrations /// Reads *all* `alertUrgentLow*` keys, converts them into a single `Alarm`, diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 49050f9a0..5e4d23366 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -162,6 +162,9 @@ class Storage { var lastLoopingChecked = StorageValue(key: "lastLoopingChecked", defaultValue: nil) + var tab2Selection = StorageValue(key: "tab2Selection", defaultValue: .alarms) + var tab4Selection = StorageValue(key: "tab4Selection", defaultValue: .nightscout) + static let shared = Storage() private init() {} } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index c698cf53c..0b496ec64 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -16,11 +16,6 @@ func IsNightscoutEnabled() -> Bool { return !Storage.shared.url.value.isEmpty } -private enum SecondTab { - case remote - case alarms -} - class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { @IBOutlet var BGText: UILabel! @IBOutlet var DeltaText: UILabel! @@ -124,11 +119,18 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() + // This step1 was released with 3.0 if Storage.shared.migrationStep.value < 1 { Storage.shared.migrateStep1() Storage.shared.migrationStep.value = 1 } + // This step2 was released with 3.1 + if Storage.shared.migrationStep.value < 2 { + Storage.shared.migrateStep2() + Storage.shared.migrationStep.value = 2 + } + // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -278,66 +280,72 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.remoteType.$value + Storage.shared.tab2Selection.$value .receive(on: DispatchQueue.main) - .sink { [weak self] remoteType in - if remoteType == .none { - // If remote is disabled, show the Alarms tab. - self?.updateSecondTab(to: .alarms) - } else { - // Otherwise, show the Remote tab. - self?.updateSecondTab(to: .remote) - } + .sink { [weak self] selection in + self?.updateTab(at: 1, to: selection) + self?.updateNightscoutTabState() + } + .store(in: &cancellables) + + Storage.shared.tab4Selection.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] selection in + self?.updateTab(at: 3, to: selection) + self?.updateNightscoutTabState() } .store(in: &cancellables) Storage.shared.url.$value .receive(on: DispatchQueue.main) - .sink { [weak self] value in - self?.tabBarController?.tabBar.items?[3].isEnabled = !value.isEmpty + .sink { [weak self] _ in + self?.updateNightscoutTabState() } .store(in: &cancellables) updateQuickActions() + updateTab(at: 1, to: Storage.shared.tab2Selection.value) + updateTab(at: 3, to: Storage.shared.tab4Selection.value) + updateNightscoutTabState() speechSynthesizer.delegate = self } - private func updateSecondTab(to tab: SecondTab) { + private func updateTab(at index: Int, to selection: TabSelection) { guard let tabBarController = tabBarController, var viewControllers = tabBarController.viewControllers, - viewControllers.count > 1 + viewControllers.count > index else { return } let storyboard = UIStoryboard(name: "Main", bundle: nil) - let newViewController: UIViewController - let newTabBarItem: UITabBarItem - - switch tab { - case .remote: - newViewController = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - newTabBarItem = UITabBarItem( - title: "Remote", - image: UIImage(systemName: "antenna.radiowaves.left.and.right"), - tag: 1 - ) - case .alarms: - newViewController = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") - newTabBarItem = UITabBarItem( - title: "Alarms", - image: UIImage(systemName: "alarm"), - tag: 1 - ) - } + let newViewController = storyboard.instantiateViewController(withIdentifier: selection.storyboardIdentifier) + let newTabBarItem = UITabBarItem( + title: selection.displayName, + image: UIImage(systemName: selection.systemImage), + tag: index + ) newViewController.tabBarItem = newTabBarItem - viewControllers[1] = newViewController + viewControllers[index] = newViewController tabBarController.setViewControllers(viewControllers, animated: false) } + private func updateNightscoutTabState() { + guard let tabBarController = tabBarController, + let viewControllers = tabBarController.viewControllers else { return } + + let isNightscoutEnabled = !Storage.shared.url.value.isEmpty + + for (index, vc) in viewControllers.enumerated() { + if vc is NightscoutViewController { + tabBarController.tabBar.items?[index].isEnabled = isNightscoutEnabled + } + } + } + // Update the Home Screen Quick Action for toggling the "Speak BG" feature based on the current speakBG setting. func updateQuickActions() { let iconName = Storage.shared.speakBG.value ? "pause.circle.fill" : "play.circle.fill" @@ -539,10 +547,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele func showHideNSDetails() { var isHidden = false - var isEnabled = true if !IsNightscoutEnabled() { isHidden = true - isEnabled = false } LoopStatusLabel.isHidden = isHidden @@ -557,12 +563,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele infoTable.isHidden = true } - if IsNightscoutEnabled() { - isEnabled = true - } - - guard let nightscoutTab = tabBarController?.tabBar.items![3] else { return } - nightscoutTab.isEnabled = isEnabled + updateNightscoutTabState() } func updateBadge(val: Int) {