From 221e11ccc30028114fbc49795f07f12b54df9f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 12 Jun 2026 23:47:46 +0200 Subject: [PATCH 01/22] Remove storyboard, migrate to SwiftUI layout (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove Main.storyboard and migrate to SwiftUI app lifecycle Replace UIKit storyboard/SceneDelegate architecture with SwiftUI App entry point (LoopFollowApp.swift) and TabView (MainTabView.swift). Convert MoreMenuViewController to SwiftUI (MoreMenuView.swift). Add SwiftUI wrappers for Remote and Nightscout tabs. Remove 6 obsolete UIKit wrapper view controllers and ~300 lines of tab management code from MainViewController. * Migrate info table from UITableView to SwiftUI List Replace UITableView with SwiftUI InfoTableView hosted in MainViewController. Make InfoManager an ObservableObject so data updates trigger SwiftUI rebuilds automatically. Remove UITableViewDataSource conformance and table delegate methods. No changes needed to the 10 Nightscout controller files that populate the table data. * Migrate statistics and pie chart from UIKit to SwiftUI Replace 7 UILabel properties and DGCharts PieChartView with a StatsDisplayModel ObservableObject and hosted StatsDisplayView. The pie chart uses a UIViewRepresentable wrapper for DGCharts since the Charts pod name shadows Swift Charts. Remove ~60 lines of UIKit stack layout code from MainViewController setupUI(). * Migrate BG display area from UIKit labels to SwiftUI Replace BGText, DirectionText, DeltaText, MinAgoText, serverText, LoopStatusLabel, and PredictionLabel with a SwiftUI BGDisplayView. Add pull-to-refresh via .refreshable modifier. Move loop status and prediction text updates to Observable values across DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, and BGData. Remove UIScrollView overlay and UIScrollViewDelegate conformance. * Migrate main layout to SwiftUI with UIKit charts embedded Replace UIStackView layout with MainHomeView SwiftUI view that composes BGDisplayView, InfoTableView, LineChartWrapper (UIViewRepresentable for DGCharts), and StatsDisplayView. MainViewController now hosts a single UIHostingController instead of managing individual UIView containers. Visibility of info table, small graph, and stats is now reactive via Storage observables in SwiftUI, removing several Combine subscriptions. BG text uses lineLimit + minimumScaleFactor instead of manual font sizing. * Clean up migration artifacts and fix post-migration bugs - Fix AVSpeechSynthesizer temporary in AppDelegate that would be deallocated before speech completes; use stored property instead - Fix appMovedToBackground tab switching to use Observable instead of dead UIKit tabBarController reference - Remove dead code: rebuildTabsIfNeeded(), updateNightscoutTabState(), traitCollectionDidChange notification relay, UIViewExtension.addBorder - Remove unused imports (Charts, UIKit, Combine) from migrated files - Remove unused synthesizer from LoopFollowApp - Remove redundant .appearanceDidChange subscription from NightscoutVC - Add missing super calls in viewWillAppear/viewDidAppear * Replace view hierarchy walking with MainViewController.shared The getMainViewController() methods in TreatmentsView, SettingsMenuView, and BackgroundRefreshManager tried to find MainViewController by casting rootViewController as UITabBarController, which always fails with the SwiftUI lifecycle. Add a weak static shared reference set during viewDidLoad and use it everywhere instead. * Fix MainViewController.shared references for stats and treatments Pass MainViewController.shared instead of nil when creating AggregatedStatsContentView in MainTabView and MoreMenuView. Replace view-hierarchy-walking getMainViewController() in TreatmentsViewModel with MainViewController.shared. * Fix info table font size to match storyboard The storyboard used system 17pt for both title and detail labels. The SwiftUI migration used .subheadline (~15pt) making text smaller. * Fix Share Logs sheet rendering blank Present UIActivityViewController via UIApplication.topMost instead of wrapping it in a SwiftUI .sheet, which rendered an empty view. * Fix back navigation from Settings sub-pages SettingsMenuView declared its own NavigationStack(path:) while already being pushed onto the outer NavigationStack from MainTabView, so sub-page back buttons popped the outer stack and jumped past Settings to Menu. Drop the nested NavigationStack and route Settings entries through the ambient stack: a single SettingsRoute enum drives a navigationDestination attached at the MoreMenuView root. The Settings entry itself becomes a NavigationLink(value:) so it doesn't compete with a navigationDestination (isPresented:) modifier, which was re-asserting Settings as the top of stack whenever a sub-page was pushed. * Harden post-storyboard migration * MainViewController is now a strong static singleton bootstrapped from LoopFollowApp.init(). Lifecycle work in viewDidLoad (Combine sinks, observers, scheduleAllTasks, migrations) runs at launch regardless of whether the Home tab is rendered, and HomeContentView reuses the singleton instead of instantiating a fresh VC each time. * MoreMenuView's eight .navigationDestination(isPresented:) modifiers are collapsed to a single MenuRoute enum routed through one .navigationDestination(for:), preventing the same destination-slot contention that previously caused Settings → Graph back navigation to jump past Settings. * MainTabView observes Storage.shared.appearanceMode so theme changes propagate; the orphaned .appearanceDidChange notification name is removed. * OPEN_APP_ACTION notification taps now dismiss any presented modal before switching to Home, matching prior SceneDelegate behavior. * Drop the unused Core Data stack (NSPersistentCloudKitContainer, saveContext) from AppDelegate, the dead AppDelegate.window property, and the legacy UIRequiredDeviceCapabilities=armv7 / UIStatusBarTintParameters keys from Info.plist. Switch AlarmSound's keyWindow access to the connected-scenes API and generalize UIApplication.topMost likewise so it works on Mac Catalyst. * Strip redundant inner NavigationView wrappers from settings sub-views pushed onto the outer NavigationStack: Graph, General, Advanced, Calendar, Contact, Dexcom, Nightscout, BackgroundRefresh, InfoDisplay, ImportExport. Drop unused onBack parameters from AlarmsContainerView and SettingsMenuView, the unused isPresentedAsModal flag from MainViewController, and the leftover debug print in ObservableValue.set. * LineChartWrapper.updateUIView now flushes the chart on SwiftUI re-render. MainViewController.deinit removes all observers, not just the custom "refresh" one. MoreMenuView caches the app version in @State instead of constructing AppVersionManager on every body re-render. HomeModalView uses NavigationStack (not deprecated NavigationView). * MoreMenuView: render tab-switch buttons in primary color Buttons in a List inherit the accent tint, so the Features rows that switch tabs appeared blue while the NavigationLink rows that push appeared white. Use .buttonStyle(.plain) to suppress the tint and drop the now-redundant .foregroundStyle(.primary) calls. * Revert MainViewController singleton bootstrap Constructing MainViewController.shared from LoopFollowApp.init() — and reusing the same VC across HomeContentView re-creations — caused tapping the BG chart to crash with `-[__NSArrayM insertObject:atIndex:]: object cannot be nil`. Bisected to the singleton+bootstrap piece of the post-storyboard hardening; the rest of that commit (programmatic UI, MoreMenuView routing, NavigationView strip-out) is retained. Restore the prior behavior: shared is a weak static set in viewDidLoad, HomeContentView constructs a fresh MainViewController each time, and the LoopFollowApp.init() bootstrap is removed. Known follow-up: lifecycle work in viewDidLoad (Combine sinks, scheduleAllTasks, migrations) again only runs when the Home view is first rendered, so a user who has moved Home off the tab bar gets degraded behavior until they navigate to it. * MoreMenuView: make tab-switch rows full-row tappable Wrap Button labels in an HStack with a trailing Spacer and contentShape so the entire row is tappable, matching the hit area of NavigationLink rows. Extract the pattern into a small FullRowButton helper, used for both tab-switch rows and Share Logs. * Align units-selection conflict resolution with integration branch Move the Diagnostics section out of Section("Speak BG") (was nested at the wrong indent), match StatsDisplayModel field order, and add the spacing line in updateStats. * MoreMenuView: fix cross-row tap routing in Features section Mixing Button and NavigationLink rows in the same List ForEach caused taps on a NavigationLink row to fire a sibling Button row's action — e.g. tapping Alarms with Stats in the tab bar would switch to the Stats tab instead of pushing the alarms detail. Make every row in the menu's List a uniform FullRowButton and drive pushes from state via .navigationDestination(isPresented:). Add an opt-in chevron to FullRowButton so navigating rows render the standard disclosure indicator. * MoreMenuView: keep Settings as a value-based NavigationLink Mixing .navigationDestination(isPresented:) with .navigationDestination(for:) on the same view shadowed the value-based SettingsRoute registration once SettingsMenuView was on the stack, so sub-rows like Units and Metrics couldn't push. Settings sits alone in its section, so it doesn't need the uniform-Button treatment used in Features and Logging — restore it to a NavigationLink and route it through the existing .navigationDestination(for:) channel. * Fix navigation between alarms and menu * Drive Before-First-Unlock recovery from AppDelegate Move BFU recovery (Storage.reloadAll) out of MainViewController and into AppDelegate so it runs even when the home tab's UIHostingController has not yet materialized — necessary under the SwiftUI App lifecycle, where a BG-only launch (BGAppRefreshTask, BLE wake, prewarming) may complete and the device may unlock without MainViewController ever being created. AppDelegate observes protectedDataDidBecomeAvailable (authoritative signal) and willEnterForeground (fallback), with a race-guard re-check immediately after observer registration. Recovery is idempotent via needsBFUReload. MainViewController now reacts to a new .bfuReloadCompleted notification by showing the loading overlay and rescheduling tasks; if it is not alive when the notification fires, its viewDidLoad will later see the already-reloaded Storage values and schedule tasks correctly on first load. * Keep MainViewController alive regardless of tab layout Make MainViewController.shared a strong, long-lived singleton created once via bootstrap() on first foreground, so the data pipeline, alarms and background audio run even when Home is moved into the Menu rather than a tab. Home views reuse the single instance instead of creating new ones, so the singleton is never displaced. Defer the one-shot BG graph zoom until the chart has a real frame and re-render the graph on every appearance, so the curve stays visible when Home is reached from the Menu or moved between tab bar and Menu. Restore the one-time telemetry consent prompt that was lost when SceneDelegate was removed, presenting it from MainTabView on first appearance for undecided installs. * Fix issues found in post-migration review - Speak BG quick action: under the SwiftUI scene lifecycle UIKit delivers Home Screen quick actions to the window scene delegate and never calls application(_:performActionFor:). Install a scene delegate via configurationForConnecting that handles warm taps, cold-launch shortcut delivery, and mirrors the Live Activity la-tap URL handling that moves with it. - Nightscout tab: show a hint instead of a blank page when no URL is configured, and recreate the web view when the URL or token changes (the page was loaded once in viewDidLoad and stayed stale until app restart). - Stats: resolve MainViewController lazily with a fallback to the shared instance, so stats work when the tab is built before bootstrap() has run (cold launch with Statistics as selected tab). - Remove the unwired NightscoutSettingsViewModel delegate chain. - Make LineChartWrapper.updateUIView a no-op; MainViewController already notifies the charts whenever it mutates their data. * Fix squished Home layout caused by phantom keyboard inset iOS sometimes replays a stale keyboard frame when the app returns to the foreground, compressing the Home screen by a keyboard's height until a rotation forces the safe area to recompute. Home has no text input, so opt out of keyboard avoidance at both hosting layers. --- LoopFollow.xcodeproj/project.pbxproj | 84 +- LoopFollow/Alarm/AlarmsContainerView.swift | 43 +- LoopFollow/Application/AppDelegate.swift | 176 ++-- .../Application/Base.lproj/Main.storyboard | 458 ----------- LoopFollow/Application/LoopFollowApp.swift | 23 + LoopFollow/Application/MainTabView.swift | 86 ++ LoopFollow/Application/SceneDelegate.swift | 121 --- .../BackgroundRefreshSettingsView.swift | 24 +- LoopFollow/Controllers/AlarmSound.swift | 6 +- LoopFollow/Controllers/Graphs.swift | 9 +- .../MainViewController+updateStats.swift | 74 +- .../Controllers/Nightscout/BGData.swift | 4 +- .../Controllers/Nightscout/DeviceStatus.swift | 30 +- .../Nightscout/DeviceStatusLoop.swift | 15 +- .../Nightscout/DeviceStatusOpenAPS.swift | 15 +- .../Controllers/StatsDisplayModel.swift | 18 + LoopFollow/Controllers/StatsDisplayView.swift | 84 ++ LoopFollow/Extensions/UIViewExtension.swift | 24 - LoopFollow/Helpers/AppearanceMode.swift | 4 - .../Helpers/BackgroundRefreshManager.swift | 32 +- LoopFollow/Helpers/Views/NavigationRow.swift | 11 +- LoopFollow/Info.plist | 35 - .../InfoDisplaySettingsView.swift | 52 +- LoopFollow/InfoTable/InfoData.swift | 8 +- LoopFollow/InfoTable/InfoManager.swift | 43 +- LoopFollow/InfoTable/InfoTableView.swift | 34 + .../NightscoutSettingsViewModel.swift | 10 - LoopFollow/Remote/RemoteContentView.swift | 48 ++ LoopFollow/Remote/RemoteViewController.swift | 126 --- .../Settings/AdvancedSettingsView.swift | 28 +- .../Settings/CalendarSettingsView.swift | 86 +- LoopFollow/Settings/ContactSettingsView.swift | 158 ++-- LoopFollow/Settings/GeneralSettingsView.swift | 174 ++-- LoopFollow/Settings/GraphSettingsView.swift | 162 ++-- LoopFollow/Settings/HomeContentView.swift | 46 +- .../ImportExportSettingsView.swift | 90 +-- LoopFollow/Settings/SettingsMenuView.swift | 240 ++---- .../Snoozer/SnoozerViewController.swift | 65 -- LoopFollow/Stats/StatsDataFetcher.swift | 10 +- LoopFollow/Stats/StatsDataService.swift | 10 +- LoopFollow/Storage/Observable.swift | 11 +- LoopFollow/Task/MinAgoTask.swift | 22 +- LoopFollow/Treatments/TreatmentsView.swift | 41 +- .../ViewControllers/AlarmViewController.swift | 60 -- .../ViewControllers/BGDisplayView.swift | 74 ++ .../ViewControllers/LineChartWrapper.swift | 20 + LoopFollow/ViewControllers/MainHomeView.swift | 70 ++ .../ViewControllers/MainViewController.swift | 765 +++--------------- LoopFollow/ViewControllers/MoreMenuView.swift | 220 +++++ .../MoreMenuViewController.swift | 466 ----------- .../NightScoutViewController.swift | 36 +- .../NightscoutContentView.swift | 38 + .../SettingsViewController.swift | 73 -- 53 files changed, 1627 insertions(+), 3035 deletions(-) delete mode 100644 LoopFollow/Application/Base.lproj/Main.storyboard create mode 100644 LoopFollow/Application/LoopFollowApp.swift create mode 100644 LoopFollow/Application/MainTabView.swift delete mode 100644 LoopFollow/Application/SceneDelegate.swift create mode 100644 LoopFollow/Controllers/StatsDisplayModel.swift create mode 100644 LoopFollow/Controllers/StatsDisplayView.swift delete mode 100644 LoopFollow/Extensions/UIViewExtension.swift create mode 100644 LoopFollow/InfoTable/InfoTableView.swift create mode 100644 LoopFollow/Remote/RemoteContentView.swift delete mode 100644 LoopFollow/Remote/RemoteViewController.swift delete mode 100644 LoopFollow/Snoozer/SnoozerViewController.swift delete mode 100644 LoopFollow/ViewControllers/AlarmViewController.swift create mode 100644 LoopFollow/ViewControllers/BGDisplayView.swift create mode 100644 LoopFollow/ViewControllers/LineChartWrapper.swift create mode 100644 LoopFollow/ViewControllers/MainHomeView.swift create mode 100644 LoopFollow/ViewControllers/MoreMenuView.swift delete mode 100644 LoopFollow/ViewControllers/MoreMenuViewController.swift create mode 100644 LoopFollow/ViewControllers/NightscoutContentView.swift delete mode 100644 LoopFollow/ViewControllers/SettingsViewController.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index f3363e164..34edc6b79 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -88,18 +88,24 @@ DD0C0C682C48529400DBADDF /* Metric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C672C48529400DBADDF /* Metric.swift */; }; DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6A2C48562000DBADDF /* InsulinMetric.swift */; }; DD0C0C6D2C48606200DBADDF /* CarbMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6C2C48606200DBADDF /* CarbMetric.swift */; }; - DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */; }; + DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */; }; DD0C0C722C4B000800DBADDF /* TrioNightscoutRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */; }; - DD12D4852E1705D9004E0112 /* AlarmViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD12D4842E1705D9004E0112 /* AlarmViewController.swift */; }; + AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */; }; + BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */; }; DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */; }; DD13BC752C3FD6210062313B /* InfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC742C3FD6200062313B /* InfoType.swift */; }; DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC762C3FD64E0062313B /* InfoData.swift */; }; DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC782C3FE63A0062313B /* InfoManager.swift */; }; + DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */; }; DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; }; - DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; }; + CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; }; + DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */; }; + DD7A3B5F2F1E8DA000B4C6E1 /* LineChartWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */; }; + DD7A3B612F1E8DA600B4C6E1 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */; }; + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */; }; DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; }; DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; @@ -219,7 +225,6 @@ DDC6CA472DD8D9010060EE25 /* PumpChangeAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC6CA462DD8D9010060EE25 /* PumpChangeAlarmEditor.swift */; }; DDC6CA492DD8E47A0060EE25 /* PumpVolumeCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC6CA482DD8E47A0060EE25 /* PumpVolumeCondition.swift */; }; DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC6CA4A2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift */; }; - DDC7E5152DBCFA7900EB1127 /* SnoozerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5132DBCE1B900EB1127 /* SnoozerViewController.swift */; }; DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5122DBCE1B900EB1127 /* SnoozerView.swift */; }; DDC7E5382DBD887400EB1127 /* isOnPhoneCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */; }; DDC7E5422DBD8A1600EB1127 /* AlarmGeneralSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5392DBD8A1600EB1127 /* AlarmGeneralSection.swift */; }; @@ -237,7 +242,6 @@ DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; }; DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; - DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A872D85FD33004DF4DD /* AlarmData.swift */; }; @@ -289,6 +293,8 @@ FC16A98124996C07003D6245 /* DateTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A98024996C07003D6245 /* DateTime.swift */; }; FC1BDD2B24A22650001B652C /* Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2A24A22650001B652C /* Stats.swift */; }; FC1BDD2D24A23204001B652C /* MainViewController+updateStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F3 /* StatsDisplayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F5 /* StatsDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */; }; FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; }; FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; }; FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; }; @@ -410,10 +416,8 @@ FC7CE59C248D33A9001F83B8 /* dragbar.png in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE59B248D33A9001F83B8 /* dragbar.png */; }; FC8589BF252B54F500C8FC73 /* Mobileprovision.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC8589BE252B54F500C8FC73 /* Mobileprovision.swift */; }; FC9788182485969B00A7906C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9788172485969B00A7906C /* AppDelegate.swift */; }; - FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9788192485969B00A7906C /* SceneDelegate.swift */; }; FC97881C2485969B00A7906C /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC97881B2485969B00A7906C /* MainViewController.swift */; }; FC97881E2485969B00A7906C /* NightScoutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC97881D2485969B00A7906C /* NightScoutViewController.swift */; }; - FC9788212485969B00A7906C /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FC97881F2485969B00A7906C /* Main.storyboard */; }; FC9788262485969C00A7906C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FC9788252485969C00A7906C /* Assets.xcassets */; }; FC9788292485969C00A7906C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FC9788272485969C00A7906C /* LaunchScreen.storyboard */; }; FCA2DDE62501095000254A8C /* Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCA2DDE52501095000254A8C /* Timers.swift */; }; @@ -431,7 +435,6 @@ FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; }; - FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEECA1248857A600402A7F /* SettingsViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -544,18 +547,24 @@ DD0C0C672C48529400DBADDF /* Metric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metric.swift; sourceTree = ""; }; DD0C0C6A2C48562000DBADDF /* InsulinMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinMetric.swift; sourceTree = ""; }; DD0C0C6C2C48606200DBADDF /* CarbMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbMetric.swift; sourceTree = ""; }; - DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteViewController.swift; sourceTree = ""; }; + DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContentView.swift; sourceTree = ""; }; DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteView.swift; sourceTree = ""; }; - DD12D4842E1705D9004E0112 /* AlarmViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmViewController.swift; sourceTree = ""; }; + AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopFollowApp.swift; sourceTree = ""; }; + BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmsContainerView.swift; sourceTree = ""; }; DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = ""; }; DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = ""; }; DD13BC782C3FE63A0062313B /* InfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoManager.swift; sourceTree = ""; }; + DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = ""; }; DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; - DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = ""; }; + CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = ""; }; + DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGDisplayView.swift; sourceTree = ""; }; + DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartWrapper.swift; sourceTree = ""; }; + DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeView.swift; sourceTree = ""; }; + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutContentView.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; @@ -676,7 +685,6 @@ DDC6CA482DD8E47A0060EE25 /* PumpVolumeCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpVolumeCondition.swift; sourceTree = ""; }; DDC6CA4A2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpVolumeAlarmEditor.swift; sourceTree = ""; }; DDC7E5122DBCE1B900EB1127 /* SnoozerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerView.swift; sourceTree = ""; }; - DDC7E5132DBCE1B900EB1127 /* SnoozerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewController.swift; sourceTree = ""; }; DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = isOnPhoneCall.swift; sourceTree = ""; }; DDC7E5392DBD8A1600EB1127 /* AlarmGeneralSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGeneralSection.swift; sourceTree = ""; }; DDC7E53A2DBD8A1600EB1127 /* SoundFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundFile.swift; sourceTree = ""; }; @@ -695,7 +703,6 @@ DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarm.swift; sourceTree = ""; }; DDCF9A812D85FD14004DF4DD /* AlarmType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmType.swift; sourceTree = ""; }; DDCF9A872D85FD33004DF4DD /* AlarmData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmData.swift; sourceTree = ""; }; @@ -747,6 +754,8 @@ FC16A98024996C07003D6245 /* DateTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTime.swift; sourceTree = ""; }; FC1BDD2A24A22650001B652C /* Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stats.swift; sourceTree = ""; }; FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+updateStats.swift"; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayModel.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayView.swift; sourceTree = ""; }; FC1BDD2E24A232A3001B652C /* DataStructs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStructs.swift; sourceTree = ""; }; FC3AE7B4249E8E0E00AAE1E0 /* LoopFollow.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LoopFollow.xcdatamodel; sourceTree = ""; }; FC5A5C3C2497B229009C550E /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; @@ -869,10 +878,8 @@ FC8DEEE62485D1ED0075863F /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FC9788142485969B00A7906C /* Loop Follow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Loop Follow.app"; sourceTree = BUILT_PRODUCTS_DIR; }; FC9788172485969B00A7906C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - FC9788192485969B00A7906C /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; FC97881B2485969B00A7906C /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; FC97881D2485969B00A7906C /* NightScoutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScoutViewController.swift; sourceTree = ""; }; - FC9788202485969B00A7906C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; FC9788252485969C00A7906C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; FC9788282485969C00A7906C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; @@ -894,7 +901,6 @@ FCEF87AA24A1417900AE6FA0 /* Localizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localizer.swift; sourceTree = ""; }; FCFEEC9D2486E68E00402A7F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; FCFEEC9F2488157B00402A7F /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = ""; }; - FCFEECA1248857A600402A7F /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -1059,7 +1065,7 @@ DD4878112C7B74F90048F05C /* TRC */, DD4878062C7B2E9E0048F05C /* Settings */, DDF699972C5AA2E50058A8D9 /* TempTargetPreset */, - DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */, + DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */, DDE69ED12C7256260013EAEC /* RemoteType.swift */, ); path = Remote; @@ -1072,6 +1078,7 @@ DD13BC762C3FD64E0062313B /* InfoData.swift */, DD13BC782C3FE63A0062313B /* InfoManager.swift */, DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */, + DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */, ); path = InfoTable; sourceTree = ""; @@ -1180,7 +1187,6 @@ isa = PBXGroup; children = ( DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */, - DDCF979324C0D380002C9752 /* UIViewExtension.swift */, DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */, DD0C0C632C45A59400DBADDF /* HKUnit+Extensions.swift */, DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */, @@ -1236,7 +1242,6 @@ children = ( DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */, DDC7E5122DBCE1B900EB1127 /* SnoozerView.swift */, - DDC7E5132DBCE1B900EB1127 /* SnoozerViewController.swift */, ); path = Snoozer; sourceTree = ""; @@ -1444,9 +1449,9 @@ FC16A97624995FEE003D6245 /* Application */ = { isa = PBXGroup; children = ( - FC97881F2485969B00A7906C /* Main.storyboard */, FC9788172485969B00A7906C /* AppDelegate.swift */, - FC9788192485969B00A7906C /* SceneDelegate.swift */, + AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */, + BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */, FC9788272485969C00A7906C /* LaunchScreen.storyboard */, ); path = Application; @@ -1462,6 +1467,8 @@ FC16A97E249969E2003D6245 /* Graphs.swift */, FC1BDD2A24A22650001B652C /* Stats.swift */, FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */, + A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */, + A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */, FCA2DDE52501095000254A8C /* Timers.swift */, DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */, 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */, @@ -1723,11 +1730,13 @@ FCC68871248A736700A0279D /* ViewControllers */ = { isa = PBXGroup; children = ( - DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */, - DD12D4842E1705D9004E0112 /* AlarmViewController.swift */, + DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */, + DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */, + DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */, + CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */, + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */, FC97881B2485969B00A7906C /* MainViewController.swift */, FC97881D2485969B00A7906C /* NightScoutViewController.swift */, - FCFEECA1248857A600402A7F /* SettingsViewController.swift */, ); path = ViewControllers; sourceTree = ""; @@ -1966,7 +1975,6 @@ FC7CE529248ABE37001F83B8 /* Laser_Shoot.caf in Resources */, FC7CE54A248ABE37001F83B8 /* Siri_Alert_Urgent_High_Glucose.caf in Resources */, FC7CE579248ABE37001F83B8 /* Good_Morning.caf in Resources */, - FC9788212485969B00A7906C /* Main.storyboard in Resources */, FC7CE538248ABE37001F83B8 /* 20ms-of-silence.caf in Resources */, FC7CE56B248ABE37001F83B8 /* Cartoon_Ascend_Then_Descend.caf in Resources */, FC7CE54E248ABE37001F83B8 /* Hell_Yeah_Somewhat_Calmer.caf in Resources */, @@ -2150,7 +2158,6 @@ DD608A0A2C23593900F91132 /* SMB.swift in Sources */, FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */, DD7F4C252DD7B20700D449E9 /* AlarmType+timeUnit.swift in Sources */, - DDC7E5152DBCFA7900EB1127 /* SnoozerViewController.swift in Sources */, DD7F4C072DD5042F00D449E9 /* OverrideStartAlarmEditor.swift in Sources */, DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */, DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */, @@ -2213,7 +2220,6 @@ 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, - FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */, 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */, DD0650F72DCFDA26004D3B41 /* InfoBanner.swift in Sources */, @@ -2224,12 +2230,12 @@ DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */, DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */, DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */, - DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */, + DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */, + DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */, DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */, AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */, FCFEECA02488157B00402A7F /* Chart.swift in Sources */, DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */, - DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */, DD0C0C6D2C48606200DBADDF /* CarbMetric.swift in Sources */, DDC7E5422DBD8A1600EB1127 /* AlarmGeneralSection.swift in Sources */, DD7F4BC72DD473A600D449E9 /* FastDropCondition.swift in Sources */, @@ -2245,7 +2251,11 @@ FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */, FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */, DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */, - DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */, + CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */, + DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */, + DD7A3B5F2F1E8DA000B4C6E1 /* LineChartWrapper.swift in Sources */, + DD7A3B612F1E8DA600B4C6E1 /* MainHomeView.swift in Sources */, + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */, 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */, DD4878152C7B75230048F05C /* MealView.swift in Sources */, FC16A97F249969E2003D6245 /* Graphs.swift in Sources */, @@ -2318,7 +2328,8 @@ DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */, DD0650EB2DCE8385004D3B41 /* LowBGCondition.swift in Sources */, - DD12D4852E1705D9004E0112 /* AlarmViewController.swift in Sources */, + AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */, + BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */, DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */, DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */, DD493AE92ACF2445009A6922 /* BGData.swift in Sources */, @@ -2344,7 +2355,6 @@ FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */, DDC6CA432DD8CED20060EE25 /* SensorAgeCondition.swift in Sources */, DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, - FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */, 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, @@ -2360,6 +2370,8 @@ DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */, DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */, FC1BDD2D24A23204001B652C /* MainViewController+updateStats.swift in Sources */, + A1B2C3D4E5F6A7B8C9D0E1F3 /* StatsDisplayModel.swift in Sources */, + A1B2C3D4E5F6A7B8C9D0E1F5 /* StatsDisplayView.swift in Sources */, DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */, DD7F4C152DD51FEB00D449E9 /* TempTargetEndAlarmEditor.swift in Sources */, FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */, @@ -2419,14 +2431,6 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - FC97881F2485969B00A7906C /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - FC9788202485969B00A7906C /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; FC9788272485969C00A7906C /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/LoopFollow/Alarm/AlarmsContainerView.swift b/LoopFollow/Alarm/AlarmsContainerView.swift index 133bea1d5..41c30172e 100644 --- a/LoopFollow/Alarm/AlarmsContainerView.swift +++ b/LoopFollow/Alarm/AlarmsContainerView.swift @@ -4,28 +4,35 @@ import SwiftUI struct AlarmsContainerView: View { - var onBack: (() -> Void)? + private let embedsInNavigationStack: Bool + + init(embedsInNavigationStack: Bool = true) { + self.embedsInNavigationStack = embedsInNavigationStack + } var body: some View { - NavigationStack { - AlarmListView() - .toolbar { - if let onBack { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: onBack) { - Image(systemName: "chevron.left") - } - } - } - ToolbarItem(placement: .navigationBarTrailing) { - NavigationLink { - AlarmSettingsView() - } label: { - Image(systemName: "gearshape") - } - } + Group { + if embedsInNavigationStack { + NavigationStack { + content } + } else { + content + } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) } + + private var content: some View { + AlarmListView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink { + AlarmSettingsView() + } label: { + Image(systemName: "gearshape") + } + } + } + } } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 831395f02..f3a643477 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -1,14 +1,12 @@ // LoopFollow // AppDelegate.swift -import CoreData +import AVFoundation import EventKit import UIKit import UserNotifications -@main class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -65,14 +63,49 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Detect Before-First-Unlock launch. If protected data is unavailable here, // StorageValues were cached from encrypted UserDefaults and need a reload - // on the first foreground after the user unlocks. + // once the device is unlocked. let bfu = !UIApplication.shared.isProtectedDataAvailable Storage.shared.needsBFUReload = bfu LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + // Recovery is driven from AppDelegate (not MainViewController) because under + // the SwiftUI App lifecycle the home tab's UIHostingController is materialized + // lazily — on a BG-only launch (BGAppRefreshTask, BLE wake) MainViewController + // may not exist when the device is unlocked, and would miss willEnterForeground. + // protectedDataDidBecomeAvailable fires the moment file protection lifts and + // is the authoritative signal; willEnterForeground is a fallback. + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + + // Race guard: protected data may have become available between the check + // above and the observer registration just now. + if Storage.shared.needsBFUReload, UIApplication.shared.isProtectedDataAvailable { + performBFUReloadIfNeeded() + } + return true } + // MARK: - BFU recovery + + @objc private func protectedDataDidBecomeAvailable() { + performBFUReloadIfNeeded() + } + + @objc private func handleWillEnterForeground() { + performBFUReloadIfNeeded() + } + + private func performBFUReloadIfNeeded() { + guard Storage.shared.needsBFUReload else { return } + Storage.shared.needsBFUReload = false + LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues") + Storage.shared.reloadAll() + LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'") + NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil) + } + func applicationWillTerminate(_: UIApplication) { #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.endOnTerminate() @@ -127,85 +160,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler(.newData) } - // MARK: - URL handling - - // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to - // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate - // handles ://la-tap for Live Activity tap navigation. - - // MARK: UISceneSession Lifecycle - func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // set the "prevent screen lock" option when the app is started - // This method doesn't seem to be working anymore. Added to view controllers as solution offered on SO UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value - return true } + // MARK: - Scene configuration + + // Under the scene-based lifecycle (which the SwiftUI App lifecycle uses), + // UIKit delivers Home Screen quick actions and opened URLs to the window + // scene delegate — application(_:performActionFor:) is never called. + // Injecting a delegate class here is the supported way to receive those + // events; SwiftUI still creates and manages the window itself. func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_: UIApplication, didDiscardSceneSessions _: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - // MARK: - Core Data stack - - lazy var persistentContainer: NSPersistentCloudKitContainer = { - /* - The persistent container for the application. This implementation - creates and returns a container, having loaded the store for the - application to it. This property is optional since there are legitimate - error conditions that could cause the creation of the store to fail. - */ - let container = NSPersistentCloudKitContainer(name: "LoopFollow") - container.loadPersistentStores(completionHandler: { _, error in - if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - return container - }() - - // MARK: - Core Data Saving support - - func saveContext() { - let context = persistentContainer.viewContext - if context.hasChanges { - do { - try context.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nserror = error as NSError - fatalError("Unresolved error \(nserror), \(nserror.userInfo)") - } + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + if connectingSceneSession.role == .windowApplication { + configuration.delegateClass = AppSceneDelegate.self } + return configuration } func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == "OPEN_APP_ACTION" { - if let window { - window.rootViewController?.dismiss(animated: true, completion: nil) - window.rootViewController?.present(MainViewController(), animated: true, completion: nil) - } + // Dismiss any presented modal/sheet so the user actually sees Home + UIApplication.shared.topMost?.dismiss(animated: true) + Observable.shared.selectedTabIndex.value = 0 } if response.actionIdentifier == "snooze" { @@ -226,6 +205,59 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } +extension Notification.Name { + /// Posted by AppDelegate after a Before-First-Unlock recovery completes + /// (Storage.reloadAll has run with the now-decrypted UserDefaults). + static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted") +} + +/// Window scene delegate installed via configurationForConnecting. SwiftUI owns +/// the window; this class only handles the events UIKit routes to the scene +/// delegate instead of the application delegate. +final class AppSceneDelegate: NSObject, UIWindowSceneDelegate { + private let speechSynthesizer = AVSpeechSynthesizer() + + /// A quick action used to cold-launch the app arrives in the connection + /// options; windowScene(_:performActionFor:) is not called for that launch. + func scene(_: UIScene, willConnectTo _: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + if let shortcutItem = connectionOptions.shortcutItem { + handleShortcutItem(shortcutItem) + } + } + + /// Called when the user taps the "Speak BG" Home Screen quick action while + /// the app is already running. + func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + completionHandler(handleShortcutItem(shortcutItem)) + } + + @discardableResult + private func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool { + guard let bundleIdentifier = Bundle.main.bundleIdentifier, + shortcutItem.type == bundleIdentifier + ".toggleSpeakBG" + else { + return false + } + Storage.shared.speakBG.value.toggle() + let message = Storage.shared.speakBG.value ? "BG Speak is now on" : "BG Speak is now off" + speechSynthesizer.speak(AVSpeechUtterance(string: message)) + return true + } + + /// With a custom scene delegate installed, UIKit delivers opened URLs here + /// rather than through SwiftUI's onOpenURL, so the Live Activity tap + /// handling from LoopFollowApp is mirrored. Posting twice is harmless — + /// the navigation it triggers is idempotent. + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } + #if !targetEnvironment(macCatalyst) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + #endif + } +} + extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_: UNUserNotificationCenter, willPresent notification: UNNotification, diff --git a/LoopFollow/Application/Base.lproj/Main.storyboard b/LoopFollow/Application/Base.lproj/Main.storyboard deleted file mode 100644 index 6441edc6c..000000000 --- a/LoopFollow/Application/Base.lproj/Main.storyboard +++ /dev/null @@ -1,458 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LoopFollow/Application/LoopFollowApp.swift b/LoopFollow/Application/LoopFollowApp.swift new file mode 100644 index 000000000..ee5e2f0fc --- /dev/null +++ b/LoopFollow/Application/LoopFollowApp.swift @@ -0,0 +1,23 @@ +// LoopFollow +// LoopFollowApp.swift + +import SwiftUI + +@main +struct LoopFollowApp: App { + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + + var body: some Scene { + WindowGroup { + MainTabView() + .onOpenURL { url in + guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return } + #if !targetEnvironment(macCatalyst) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + #endif + } + } + } +} diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift new file mode 100644 index 000000000..140204358 --- /dev/null +++ b/LoopFollow/Application/MainTabView.swift @@ -0,0 +1,86 @@ +// LoopFollow +// MainTabView.swift + +import SwiftUI + +struct MainTabView: View { + @ObservedObject private var selectedTab = Observable.shared.selectedTabIndex + @ObservedObject private var appearanceMode = Storage.shared.appearanceMode + @ObservedObject private var homePosition = Storage.shared.homePosition + @ObservedObject private var alarmsPosition = Storage.shared.alarmsPosition + @ObservedObject private var remotePosition = Storage.shared.remotePosition + @ObservedObject private var nightscoutPosition = Storage.shared.nightscoutPosition + @ObservedObject private var snoozerPosition = Storage.shared.snoozerPosition + @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition + @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition + + @State private var showTelemetryConsent = false + + private var orderedItems: [TabItem] { + Storage.shared.orderedTabBarItems() + } + + var body: some View { + TabView(selection: $selectedTab.value) { + ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in + tabContent(for: item) + .tabItem { + Label(item.displayName, systemImage: item.icon) + } + .tag(index) + } + + NavigationStack { + MoreMenuView() + } + .tabItem { + Label("Menu", systemImage: "line.3.horizontal") + } + .tag(4) + } + .preferredColorScheme(appearanceMode.value.colorScheme) + .onAppear { + // Start the data pipeline as soon as the UI appears, independent of + // tab layout. Without this, a user who moves Home into the Menu would + // have no MainViewController — and therefore no data fetching, alarms, + // or background audio — until they manually opened Home. Tying it to + // onAppear (not app launch) keeps it off the BG-only refresh path. + MainViewController.bootstrap() + + // One-time consent prompt. Previously presented by SceneDelegate, + // which was removed in the storyboard→SwiftUI migration; without + // this, fresh installs stay permanently undecided and telemetry + // never sends. The storage flag keeps it to a single appearance. + if !Storage.shared.telemetryConsentDecisionMade.value { + showTelemetryConsent = true + } + } + .sheet(isPresented: $showTelemetryConsent) { + // User must explicitly choose — no swipe-to-dismiss. + TelemetryConsentView() + .interactiveDismissDisabled(true) + } + } + + @ViewBuilder + private func tabContent(for item: TabItem) -> some View { + switch item { + case .home: + HomeContentView() + case .alarms: + AlarmsContainerView() + case .remote: + RemoteContentView() + case .nightscout: + NightscoutContentView() + case .snoozer: + SnoozerView() + case .treatments: + TreatmentsView() + case .stats: + NavigationStack { + AggregatedStatsContentView(mainViewController: MainViewController.shared) + } + } + } +} diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift deleted file mode 100644 index 526eb7a78..000000000 --- a/LoopFollow/Application/SceneDelegate.swift +++ /dev/null @@ -1,121 +0,0 @@ -// LoopFollow -// SceneDelegate.swift - -import AVFoundation -import SwiftUI -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - let synthesizer = AVSpeechSynthesizer() - - /// One-shot guard so the consent prompt is only attempted once per - /// process lifetime even if the scene activates repeatedly. - private var consentPromptShownThisProcess = false - - func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } - - // get the tabBar - guard let tabBarController = window?.rootViewController as? UITabBarController, - let viewControllers = tabBarController.viewControllers - else { - return - } - } - - func sceneDidDisconnect(_: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - runTelemetryFirstForegroundHook() - } - - /// Presents the one-time consent sheet on first foreground. Sending is - /// handled by AppDelegate at launch and by TaskScheduler thereafter — - /// firing maybeSend here would duplicate the launch-time send. - private func runTelemetryFirstForegroundHook() { - if !Storage.shared.telemetryConsentDecisionMade.value, - !consentPromptShownThisProcess - { - consentPromptShownThisProcess = true - presentTelemetryConsentSheet() - } - } - - private func presentTelemetryConsentSheet() { - guard let root = window?.rootViewController else { return } - // Find the topmost presented controller so we don't try to present - // over a sheet that's already up. - var top = root - while let presented = top.presentedViewController { - top = presented - } - - let host = UIHostingController(rootView: TelemetryConsentView()) - host.isModalInPresentation = true // user must explicitly choose - // Defer to the next runloop so view hierarchy is settled when the - // scene first becomes active on a fresh install. - DispatchQueue.main.async { - top.present(host, animated: true) - } - } - - func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } - // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app - // foregrounds from background. Post on the next run loop so the view - // hierarchy (including any presented modals) is fully settled. - #if !targetEnvironment(macCatalyst) - DispatchQueue.main.async { - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } - #endif - } - - func sceneWillResignActive(_: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - - // Save changes in the application's managed object context when the application transitions to the background. - (UIApplication.shared.delegate as? AppDelegate)?.saveContext() - } - - /// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. - func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) { - if let bundleIdentifier = Bundle.main.bundleIdentifier { - let expectedType = bundleIdentifier + ".toggleSpeakBG" - if shortcutItem.type == expectedType { - Storage.shared.speakBG.value.toggle() - let message = Storage.shared.speakBG.value ? "BG Speak is now on" : "BG Speak is now off" - let utterance = AVSpeechUtterance(string: message) - synthesizer.speak(utterance) - } - } - } - - /// The following method is called when the user taps on the Home Screen Quick Action - func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) { - handleShortcutItem(shortcutItem) - } -} diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift index a50bc6ea9..b03a67e68 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -11,22 +11,20 @@ struct BackgroundRefreshSettingsView: View { @ObservedObject var bleManager = BLEManager.shared var body: some View { - NavigationView { - Form { - refreshTypeSection + Form { + refreshTypeSection - if viewModel.backgroundRefreshType.isBluetooth { - selectedDeviceSection - availableDevicesSection - } - } - .onAppear { - startTimer() - } - .onDisappear { - stopTimer() + if viewModel.backgroundRefreshType.isBluetooth { + selectedDeviceSection + availableDevicesSection } } + .onAppear { + startTimer() + } + .onDisappear { + stopTimer() + } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Background Refresh Settings", displayMode: .inline) } diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index b83383185..7f32fd181 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -339,7 +339,11 @@ extension MPVolumeView { slider?.value = volume } // Optional - Remove the HUD - if let app = UIApplication.shared.delegate as? AppDelegate, let window = app.window { + let activeWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows.first(where: \.isKeyWindow) + if let window = activeWindow { volumeView.alpha = 0.000001 window.addSubview(volumeView) } diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index 9789e9c46..e8a60be7d 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -882,7 +882,14 @@ extension MainViewController { BGChartFull.data?.notifyDataChanged() BGChartFull.notifyDataSetChanged() - if firstGraphLoad { + // The initial zoom is a one-shot, relative to the chart's current + // viewport. Skip it until the chart actually has a width — otherwise a + // refresh that lands while the view is loaded but off-screen (e.g. Home + // lives in the Menu, so MainViewController is force-loaded headless by + // bootstrap()) would consume firstGraphLoad against a zero-size viewport + // and leave the main graph blank. viewDidAppear re-runs updateBGGraph + // once a real frame exists, applying the zoom correctly. + if firstGraphLoad, BGChart.bounds.width > 0 { var scaleX = CGFloat(Storage.shared.chartScaleX.value) if scaleX > CGFloat(ScaleXMax) { scaleX = CGFloat(ScaleXMax) diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index 890307d4d..f3c4c0d10 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -1,9 +1,7 @@ // LoopFollow // MainViewController+updateStats.swift -import Charts import Foundation -import UIKit extension MainViewController { func updateStats() { @@ -22,75 +20,33 @@ extension MainViewController { let stats = StatsData(bgData: lastDayOfData) - statsLowPercent.text = String(format: "%.1f%%", stats.percentLow) - statsInRangePercent.text = String(format: "%.1f%%", stats.percentRange) - statsHighPercent.text = String(format: "%.1f%%", stats.percentHigh) - statsAvgBG.text = Localizer.toDisplayUnits(String(format: "%.0f", stats.avgBG)) - statsEstA1CTitle.text = UnitSettingsStore.shared.glycemicMetricMode == .gmi ? "GMI:" : "Est. A1C:" + statsDisplayModel.lowPercent = String(format: "%.1f%%", stats.percentLow) + statsDisplayModel.inRangePercent = String(format: "%.1f%%", stats.percentRange) + statsDisplayModel.highPercent = String(format: "%.1f%%", stats.percentHigh) + statsDisplayModel.avgBG = Localizer.toDisplayUnits(String(format: "%.0f", stats.avgBG)) + statsDisplayModel.estA1CTitle = UnitSettingsStore.shared.glycemicMetricMode == .gmi ? "GMI:" : "Est. A1C:" if UnitSettingsStore.shared.glycemicOutputUnit == .mmolMol { - statsEstA1C.text = String(format: "%.0f", stats.a1C) + statsDisplayModel.estA1C = String(format: "%.0f", stats.a1C) } else { - statsEstA1C.text = String(format: "%.1f", stats.a1C) + statsDisplayModel.estA1C = String(format: "%.1f", stats.a1C) } if UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation { - statsStdDevTitle.text = "Std Dev:" + statsDisplayModel.stdDevTitle = "Std Dev:" if UnitSettingsStore.shared.glucoseUnit == .mgdL { - statsStdDev.text = String(format: "%.0f", stats.stdDev) + statsDisplayModel.stdDev = String(format: "%.0f", stats.stdDev) } else { - statsStdDev.text = String(format: "%.1f", stats.stdDev) + statsDisplayModel.stdDev = String(format: "%.1f", stats.stdDev) } } else { - statsStdDevTitle.text = "CV:" - statsStdDev.text = String(format: "%.1f%%", stats.coefficientOfVariation) + statsDisplayModel.stdDevTitle = "CV:" + statsDisplayModel.stdDev = String(format: "%.1f%%", stats.coefficientOfVariation) } - createStatsPie(pieData: stats.pie) + statsDisplayModel.pieLow = Double(stats.percentLow) + statsDisplayModel.pieRange = Double(stats.percentRange) + statsDisplayModel.pieHigh = Double(stats.percentHigh) } } - - fileprivate func createStatsPie(pieData: [DataStructs.pieData]) { - statsPieChart.legend.enabled = false - statsPieChart.drawEntryLabelsEnabled = false - statsPieChart.drawHoleEnabled = false - statsPieChart.rotationEnabled = false - - var chartEntry = [PieChartDataEntry]() - var colors = [NSUIColor]() - - for i in 0 ..< pieData.count { - var slice = Double(pieData[i].value) - if slice == 0 { slice = 0.1 } - let value = PieChartDataEntry(value: slice) - chartEntry.append(value) - - if pieData[i].name == "high" { - colors.append(NSUIColor.systemYellow) - } else if pieData[i].name == "low" { - colors.append(NSUIColor.systemRed) - } else { - colors.append(NSUIColor.systemGreen) - } - } - - let set = PieChartDataSet(entries: chartEntry, label: "") - - set.drawIconsEnabled = false - set.sliceSpace = 2 - set.drawValuesEnabled = false - set.valueLineWidth = 0 - set.formLineWidth = 0 - set.sliceSpace = 0 - - set.colors.removeAll() - if colors.count > 0 { - for i in 0 ..< colors.count { - set.addColor(colors[i]) - } - } - - let data = PieChartData(dataSet: set) - statsPieChart.data = data - } } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index e735c8db9..4aa2a02ae 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -216,9 +216,9 @@ extension MainViewController { func updateServerText(with serverText: String? = nil) { if Storage.shared.showDisplayName.value, let displayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String { - self.serverText.text = displayName + Observable.shared.serverText.value = displayName } else if let serverText = serverText { - self.serverText.text = serverText + Observable.shared.serverText.value = serverText } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ff2b13a78..7a3935045 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -1,10 +1,9 @@ // LoopFollow // DeviceStatus.swift -import Charts import Foundation import HealthKit -import UIKit +import SwiftUI extension MainViewController { func webLoadNSDeviceStatus() { @@ -36,7 +35,6 @@ extension MainViewController { } func evaluateNotLooping() { - guard let statusStackView = LoopStatusLabel.superview as? UIStackView else { return } guard let lastLoopTime = Observable.shared.alertLastLoopTime.value, lastLoopTime > 0 else { return } @@ -47,15 +45,9 @@ extension MainViewController { if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 { IsNotLooping = true Observable.shared.isNotLooping.value = true - statusStackView.distribution = .fill - PredictionLabel.isHidden = true - LoopStatusLabel.frame = CGRect(x: 0, y: 0, width: statusStackView.frame.width, height: statusStackView.frame.height) - - LoopStatusLabel.textAlignment = .center - LoopStatusLabel.text = "⚠️ Not Looping!" - LoopStatusLabel.textColor = UIColor.systemYellow - LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18) + Observable.shared.loopStatusText.value = "⚠️ Not Looping!" + Observable.shared.loopStatusColor.value = .yellow #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") #endif @@ -63,20 +55,8 @@ extension MainViewController { } else { IsNotLooping = false Observable.shared.isNotLooping.value = false - statusStackView.distribution = .fillEqually - PredictionLabel.isHidden = false - - LoopStatusLabel.textAlignment = .right - LoopStatusLabel.font = UIFont.systemFont(ofSize: 17) - - switch Storage.shared.appearanceMode.value { - case .dark: - LoopStatusLabel.textColor = UIColor.white - case .light: - LoopStatusLabel.textColor = UIColor.black - case .system: - LoopStatusLabel.textColor = UIColor.label - } + + Observable.shared.loopStatusColor.value = .primary #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") #endif diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 56ebb6af0..771fdc68d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -1,10 +1,9 @@ // LoopFollow // DeviceStatusLoop.swift -import Charts import Foundation import HealthKit -import UIKit +import SwiftUI extension MainViewController { func DeviceStatusLoop(formatter: ISO8601DateFormatter, lastLoopRecord: [String: AnyObject]) { @@ -18,7 +17,7 @@ extension MainViewController { let lastLoopTime = Observable.shared.alertLastLoopTime.value ?? 0 if lastLoopRecord["failureReason"] != nil { - LoopStatusLabel.text = "X" + Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { var wasEnacted = false @@ -67,8 +66,8 @@ extension MainViewController { if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { let prediction = predictdata["values"] as! [Double] - PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) - PredictionLabel.textColor = UIColor.systemPurple + Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) + Observable.shared.predictionColor.value = .purple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime { predictionData.removeAll() var predictionTime = lastLoopTime @@ -113,15 +112,15 @@ extension MainViewController { lastBGTime = bgData[bgData.count - 1].date } if tempBasalTime > lastBGTime, !wasEnacted { - LoopStatusLabel.text = "⏀" + Observable.shared.loopStatusText.value = "⏀" latestLoopStatusString = "⏀" } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } } } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index bc4da5ebd..4d4105fbb 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -9,11 +9,11 @@ extension MainViewController { func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) { Storage.shared.device.value = lastDeviceStatus?["device"] as? String ?? "" if lastLoopRecord["failureReason"] != nil { - LoopStatusLabel.text = "X" + Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { guard let enactedOrSuggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? lastLoopRecord["enacted"] as? [String: AnyObject] else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" return } @@ -117,7 +117,7 @@ extension MainViewController { // Eventual BG if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) - PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity) + Observable.shared.predictionText.value = Localizer.formatQuantity(eventualBGQuantity) Storage.shared.projectedBgMgdl.value = eventualBGValue } else { Storage.shared.projectedBgMgdl.value = nil @@ -173,8 +173,7 @@ extension MainViewController { return nil }() - let predictioncolor = UIColor.systemGray - PredictionLabel.textColor = predictioncolor + Observable.shared.predictionColor.value = .gray topPredictionBG = Storage.shared.minBGScale.value if let predbgdata = predBGsData { @@ -218,15 +217,15 @@ extension MainViewController { lastBGTime = bgData[bgData.count - 1].date } if tempBasalTime > lastBGTime { - LoopStatusLabel.text = "⏀" + Observable.shared.loopStatusText.value = "⏀" latestLoopStatusString = "⏀" } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } } } else { - LoopStatusLabel.text = "↻" + Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" } diff --git a/LoopFollow/Controllers/StatsDisplayModel.swift b/LoopFollow/Controllers/StatsDisplayModel.swift new file mode 100644 index 000000000..72e60560a --- /dev/null +++ b/LoopFollow/Controllers/StatsDisplayModel.swift @@ -0,0 +1,18 @@ +// LoopFollow +// StatsDisplayModel.swift + +import Foundation + +class StatsDisplayModel: ObservableObject { + @Published var lowPercent: String = "" + @Published var inRangePercent: String = "" + @Published var highPercent: String = "" + @Published var avgBG: String = "" + @Published var estA1C: String = "" + @Published var estA1CTitle: String = "Est A1C:" + @Published var stdDev: String = "" + @Published var stdDevTitle: String = "Std Dev:" + @Published var pieLow: Double = 0 + @Published var pieRange: Double = 0 + @Published var pieHigh: Double = 0 +} diff --git a/LoopFollow/Controllers/StatsDisplayView.swift b/LoopFollow/Controllers/StatsDisplayView.swift new file mode 100644 index 000000000..f19be5543 --- /dev/null +++ b/LoopFollow/Controllers/StatsDisplayView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// StatsDisplayView.swift + +import Charts +import SwiftUI + +struct StatsDisplayView: View { + @ObservedObject var model: StatsDisplayModel + var onTap: (() -> Void)? + + var body: some View { + HStack { + StatsPieChartView( + pieLow: model.pieLow, + pieRange: model.pieRange, + pieHigh: model.pieHigh + ) + .frame(width: 100, height: 100) + + VStack(spacing: 10) { + HStack { + statColumn(title: "Low:", value: model.lowPercent) + statColumn(title: "In Range:", value: model.inRangePercent) + statColumn(title: "High:", value: model.highPercent) + } + HStack { + statColumn(title: "Avg BG:", value: model.avgBG) + statColumn(title: model.estA1CTitle, value: model.estA1C) + statColumn(title: model.stdDevTitle, value: model.stdDev) + } + } + .frame(maxWidth: .infinity) + } + .frame(height: 100) + .background(Color(.secondarySystemBackground)) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + } + + private func statColumn(title: String, value: String) -> some View { + VStack { + Text(title) + .font(.system(size: 15)) + Text(value) + .font(.system(size: 15)) + } + .frame(maxWidth: .infinity) + } +} + +struct StatsPieChartView: UIViewRepresentable { + var pieLow: Double + var pieRange: Double + var pieHigh: Double + + func makeUIView(context _: Context) -> PieChartView { + let chart = PieChartView() + chart.legend.enabled = false + chart.drawEntryLabelsEnabled = false + chart.drawHoleEnabled = false + chart.rotationEnabled = false + chart.isUserInteractionEnabled = false + chart.backgroundColor = .clear + return chart + } + + func updateUIView(_ chart: PieChartView, context _: Context) { + let entries = [ + PieChartDataEntry(value: max(pieLow, 0.1)), + PieChartDataEntry(value: max(pieRange, 0.1)), + PieChartDataEntry(value: max(pieHigh, 0.1)), + ] + + let dataSet = PieChartDataSet(entries: entries, label: "") + dataSet.drawIconsEnabled = false + dataSet.sliceSpace = 0 + dataSet.drawValuesEnabled = false + dataSet.valueLineWidth = 0 + dataSet.formLineWidth = 0 + dataSet.colors = [.systemRed, .systemGreen, .systemYellow] + + chart.data = PieChartData(dataSet: dataSet) + } +} diff --git a/LoopFollow/Extensions/UIViewExtension.swift b/LoopFollow/Extensions/UIViewExtension.swift deleted file mode 100644 index 90fb15cb6..000000000 --- a/LoopFollow/Extensions/UIViewExtension.swift +++ /dev/null @@ -1,24 +0,0 @@ -// LoopFollow -// UIViewExtension.swift - -import Foundation -import UIKit - -extension UIView { - enum ViewSide { - case Left, Right, Top, Bottom - } - - func addBorder(toSide side: ViewSide, withColor color: CGColor, andThickness thickness: CGFloat) { - let border = CALayer() - border.backgroundColor = color - - switch side { - case .Left: border.frame = CGRect(x: 0, y: 0, width: thickness, height: frame.height) - case .Right: border.frame = CGRect(x: frame.width - thickness, y: 0, width: thickness, height: frame.height) - case .Top: border.frame = CGRect(x: 0, y: 0, width: frame.width, height: thickness) - case .Bottom: border.frame = CGRect(x: 0, y: frame.height - thickness, width: frame.width, height: thickness) - } - layer.addSublayer(border) - } -} diff --git a/LoopFollow/Helpers/AppearanceMode.swift b/LoopFollow/Helpers/AppearanceMode.swift index 2e3c7bc64..09feb73ba 100644 --- a/LoopFollow/Helpers/AppearanceMode.swift +++ b/LoopFollow/Helpers/AppearanceMode.swift @@ -3,10 +3,6 @@ import SwiftUI -extension Notification.Name { - static let appearanceDidChange = Notification.Name("appearanceDidChange") -} - enum AppearanceMode: String, CaseIterable, Codable { case system case light diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index a1168174d..ee34a4d3c 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -61,36 +61,6 @@ class BackgroundRefreshManager { } private func getMainViewController() -> MainViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController - else { - return nil - } - - if let mainVC = rootVC as? MainViewController { - return mainVC - } - - if let navVC = rootVC as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - - if let tabVC = rootVC as? UITabBarController { - for vc in tabVC.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - } - - return nil + MainViewController.shared } } diff --git a/LoopFollow/Helpers/Views/NavigationRow.swift b/LoopFollow/Helpers/Views/NavigationRow.swift index be38ebfe4..2198a83e2 100644 --- a/LoopFollow/Helpers/Views/NavigationRow.swift +++ b/LoopFollow/Helpers/Views/NavigationRow.swift @@ -3,23 +3,18 @@ import SwiftUI -struct NavigationRow: View { +struct NavigationRow: View { let title: String let icon: String var iconTint: Color = .white - let action: () -> Void + let value: Value var body: some View { - Button(action: action) { + NavigationLink(value: value) { HStack { Glyph(symbol: icon, tint: iconTint) Text(title) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(Color(uiColor: .tertiaryLabel)) } - .contentShape(Rectangle()) } - .buttonStyle(.plain) } } diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 9e0f99340..898d09616 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -66,25 +66,6 @@ NSSupportsLiveActivities - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - UIBackgroundModes audio @@ -97,22 +78,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UIStatusBarTintParameters - - UINavigationBar - - Style - UIBarStyleDefault - Translucent - - - UISupportedInterfaceOrientations UIInterfaceOrientationLandscapeLeft diff --git a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift index 84d28c165..d3b589f5d 100644 --- a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift +++ b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift @@ -7,38 +7,36 @@ struct InfoDisplaySettingsView: View { @ObservedObject var viewModel: InfoDisplaySettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("General")) { - Toggle(isOn: Binding( - get: { Storage.shared.hideInfoTable.value }, - set: { Storage.shared.hideInfoTable.value = $0 } - )) { - Text("Hide Information Table") - } + Form { + Section(header: Text("General")) { + Toggle(isOn: Binding( + get: { Storage.shared.hideInfoTable.value }, + set: { Storage.shared.hideInfoTable.value = $0 } + )) { + Text("Hide Information Table") } + } - Section(header: Text("Information Display Settings")) { - ForEach(viewModel.infoSort, id: \.self) { sortedIndex in - HStack { - Text(viewModel.getName(for: sortedIndex)) - Spacer() - Toggle("", isOn: Binding( - get: { viewModel.infoVisible[sortedIndex] }, - set: { _ in - viewModel.toggleVisibility(for: sortedIndex) - } - )) - .labelsHidden() - } + Section(header: Text("Information Display Settings")) { + ForEach(viewModel.infoSort, id: \.self) { sortedIndex in + HStack { + Text(viewModel.getName(for: sortedIndex)) + Spacer() + Toggle("", isOn: Binding( + get: { viewModel.infoVisible[sortedIndex] }, + set: { _ in + viewModel.toggleVisibility(for: sortedIndex) + } + )) + .labelsHidden() } - .onMove(perform: viewModel.move) } + .onMove(perform: viewModel.move) } - .environment(\.editMode, .constant(.active)) - .onDisappear { - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - } + } + .environment(\.editMode, .constant(.active)) + .onDisappear { + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Information Display Settings", displayMode: .inline) diff --git a/LoopFollow/InfoTable/InfoData.swift b/LoopFollow/InfoTable/InfoData.swift index f98e58b80..30f5bf9e9 100644 --- a/LoopFollow/InfoTable/InfoData.swift +++ b/LoopFollow/InfoTable/InfoData.swift @@ -3,11 +3,13 @@ import Foundation -class InfoData { - var name: String +class InfoData: Identifiable { + let id: Int + let name: String var value: String - init(name: String, value: String = "") { + init(id: Int, name: String, value: String = "") { + self.id = id self.name = name self.value = value } diff --git a/LoopFollow/InfoTable/InfoManager.swift b/LoopFollow/InfoTable/InfoManager.swift index f6af82629..f3205511e 100644 --- a/LoopFollow/InfoTable/InfoManager.swift +++ b/LoopFollow/InfoTable/InfoManager.swift @@ -1,22 +1,20 @@ // LoopFollow // InfoManager.swift +import Combine import Foundation import HealthKit -import UIKit -class InfoManager { - var tableData: [InfoData] - weak var tableView: UITableView? +class InfoManager: ObservableObject { + @Published var tableData: [InfoData] - init(tableView: UITableView) { - tableData = InfoType.allCases.map { InfoData(name: $0.name) } - self.tableView = tableView + init() { + tableData = InfoType.allCases.map { InfoData(id: $0.rawValue, name: $0.name) } } func updateInfoData(type: InfoType, value: String) { tableData[type.rawValue].value = value - tableView?.reloadData() + objectWillChange.send() } func updateInfoData(type: InfoType, value: HKQuantity) { @@ -55,33 +53,22 @@ class InfoManager { func clearInfoData(type: InfoType) { tableData[type.rawValue].value = "" - tableView?.reloadData() + objectWillChange.send() } func clearInfoData(types: [InfoType]) { for type in types { tableData[type.rawValue].value = "" } - tableView?.reloadData() + objectWillChange.send() } - func numberOfRows() -> Int { - return Storage.shared.infoSort.value.filter { Storage.shared.infoVisible.value[$0] }.count - } - - func dataForIndexPath(_ indexPath: IndexPath) -> InfoData? { - let sortedAndVisibleIndexes = Storage.shared.infoSort.value.filter { Storage.shared.infoVisible.value[$0] } - - guard indexPath.row < sortedAndVisibleIndexes.count else { - return nil - } - - let infoIndex = sortedAndVisibleIndexes[indexPath.row] - - guard infoIndex < tableData.count else { - return nil - } - - return tableData[infoIndex] + var visibleRows: [InfoData] { + Storage.shared.infoSort.value + .filter { $0 < Storage.shared.infoVisible.value.count && Storage.shared.infoVisible.value[$0] } + .compactMap { index in + guard index < tableData.count else { return nil } + return tableData[index] + } } } diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift new file mode 100644 index 000000000..218b3fe70 --- /dev/null +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -0,0 +1,34 @@ +// LoopFollow +// InfoTableView.swift + +import SwiftUI + +struct InfoTableView: View { + @ObservedObject var infoManager: InfoManager + var timeZoneOverride: String? + + var body: some View { + List { + if let tz = timeZoneOverride { + row(name: "Time Zone", value: tz) + } + ForEach(infoManager.visibleRows) { item in + row(name: item.name, value: item.value) + } + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 21) + } + + private func row(name: String, value: String) -> some View { + HStack { + Text(name) + Spacer() + Text(value) + .foregroundStyle(.primary) + } + .font(.system(size: 17)) + .frame(height: 21) + .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) + } +} diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 9b336be2b..e3a778a7f 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -4,13 +4,7 @@ import Combine import Foundation -protocol NightscoutSettingsViewModelDelegate: AnyObject { - func nightscoutSettingsDidFinish() -} - class NightscoutSettingsViewModel: ObservableObject { - weak var delegate: NightscoutSettingsViewModelDelegate? - private var initialURL: String private var initialToken: String @@ -147,8 +141,4 @@ class NightscoutSettingsViewModel: ObservableObject { } } } - - func dismiss() { - delegate?.nightscoutSettingsDidFinish() - } } diff --git a/LoopFollow/Remote/RemoteContentView.swift b/LoopFollow/Remote/RemoteContentView.swift new file mode 100644 index 000000000..d5694801e --- /dev/null +++ b/LoopFollow/Remote/RemoteContentView.swift @@ -0,0 +1,48 @@ +// LoopFollow +// RemoteContentView.swift + +import SwiftUI + +struct RemoteContentView: View { + @ObservedObject private var device = Storage.shared.device + @ObservedObject private var remoteType = Storage.shared.remoteType + + var body: some View { + Group { + switch remoteType.value { + case .nightscout: + if device.value == "Trio" { + TrioNightscoutRemoteView() + } else { + NoRemoteView() + } + + case .trc: + if device.value == "Trio" { + TrioRemoteControlView(viewModel: TrioRemoteControlViewModel()) + } else { + Text("Trio Remote Control is only supported for 'Trio'") + } + + case .loopAPNS: + LoopAPNSRemoteView() + + case .none: + Text("Please select a Remote Type in Settings.") + } + } + .onAppear { + verifyNightscoutAuth() + } + } + + private func verifyNightscoutAuth() { + guard remoteType.value == .nightscout, !Storage.shared.nsWriteAuth.value else { return } + NightscoutUtils.verifyURLAndToken { _, _, nsWriteAuth, nsAdminAuth in + DispatchQueue.main.async { + Storage.shared.nsWriteAuth.value = nsWriteAuth + Storage.shared.nsAdminAuth.value = nsAdminAuth + } + } + } +} diff --git a/LoopFollow/Remote/RemoteViewController.swift b/LoopFollow/Remote/RemoteViewController.swift deleted file mode 100644 index 56e317da3..000000000 --- a/LoopFollow/Remote/RemoteViewController.swift +++ /dev/null @@ -1,126 +0,0 @@ -// LoopFollow -// RemoteViewController.swift - -import Combine -import SwiftUI -import UIKit - -class RemoteViewController: UIViewController { - private var cancellables = Set() - private var hostingController: UIHostingController? - - override func viewDidLoad() { - super.viewDidLoad() - - // Apply initial appearance - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - Storage.shared.device.$value - .removeDuplicates() - .sink { [weak self] _ in - DispatchQueue.main.async { - self?.updateView() - } - } - .store(in: &cancellables) - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.overrideUserInterfaceStyle = mode.userInterfaceStyle - self?.hostingController?.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - let style = Storage.shared.appearanceMode.value.userInterfaceStyle - self?.overrideUserInterfaceStyle = style - self?.hostingController?.overrideUserInterfaceStyle = style - } - .store(in: &cancellables) - } - - private func updateView() { - let remoteType = Storage.shared.remoteType.value - - if let existingHostingController = hostingController { - existingHostingController.willMove(toParent: nil) - existingHostingController.view.removeFromSuperview() - existingHostingController.removeFromParent() - } - - if remoteType == .nightscout { - var remoteView: AnyView - - switch Storage.shared.device.value { - case "Trio": - remoteView = AnyView(TrioNightscoutRemoteView()) - default: - remoteView = AnyView(NoRemoteView()) - } - - hostingController = UIHostingController(rootView: remoteView) - } else if remoteType == .trc { - if Storage.shared.device.value != "Trio" { - hostingController = UIHostingController( - rootView: AnyView( - Text("Trio Remote Control is only supported for 'Trio'") - ) - ) - } else { - let trioRemoteControlViewModel = TrioRemoteControlViewModel() - let trioRemoteControlView = TrioRemoteControlView(viewModel: trioRemoteControlViewModel) - hostingController = UIHostingController(rootView: AnyView(trioRemoteControlView)) - } - } else if remoteType == .loopAPNS { - hostingController = UIHostingController(rootView: AnyView(LoopAPNSRemoteView())) - } else { - hostingController = UIHostingController(rootView: AnyView(Text("Please select a Remote Type in Settings."))) - } - - if let hostingController = hostingController { - addChild(hostingController) - view.addSubview(hostingController.view) - - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - hostingController.didMove(toParent: self) - } - - if remoteType == .nightscout, !Storage.shared.nsWriteAuth.value { - NightscoutUtils.verifyURLAndToken { _, _, nsWriteAuth, nsAdminAuth in - DispatchQueue.main.async { - Storage.shared.nsWriteAuth.value = nsWriteAuth - Storage.shared.nsAdminAuth.value = nsAdminAuth - } - } - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - updateView() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - let style = Storage.shared.appearanceMode.value.userInterfaceStyle - overrideUserInterfaceStyle = style - hostingController?.overrideUserInterfaceStyle = style - } - } -} diff --git a/LoopFollow/Settings/AdvancedSettingsView.swift b/LoopFollow/Settings/AdvancedSettingsView.swift index 1873aabf5..9665df882 100644 --- a/LoopFollow/Settings/AdvancedSettingsView.swift +++ b/LoopFollow/Settings/AdvancedSettingsView.swift @@ -7,24 +7,22 @@ struct AdvancedSettingsView: View { @ObservedObject var viewModel: AdvancedSettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("Advanced Settings")) { - Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) - Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) - Toggle("Graph Basal", isOn: $viewModel.graphBasal) - Toggle("Graph Bolus", isOn: $viewModel.graphBolus) - Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) - Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments) + Form { + Section(header: Text("Advanced Settings")) { + Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) + Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) + Toggle("Graph Basal", isOn: $viewModel.graphBasal) + Toggle("Graph Bolus", isOn: $viewModel.graphBolus) + Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) + Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments) - Stepper(value: $viewModel.bgUpdateDelay, in: 1 ... 30, step: 1) { - Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") - } + Stepper(value: $viewModel.bgUpdateDelay, in: 1 ... 30, step: 1) { + Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") } + } - Section(header: Text("Logging Options")) { - Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel) - } + Section(header: Text("Logging Options")) { + Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel) } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) diff --git a/LoopFollow/Settings/CalendarSettingsView.swift b/LoopFollow/Settings/CalendarSettingsView.swift index 704ef5e2f..40f882582 100644 --- a/LoopFollow/Settings/CalendarSettingsView.swift +++ b/LoopFollow/Settings/CalendarSettingsView.swift @@ -20,59 +20,57 @@ struct CalendarSettingsView: View { // MARK: Body var body: some View { - NavigationView { - Form { - // ------------- Calendar write ------------- - Section { - Toggle("Save BG to Calendar", - isOn: $writeCalendarEvent.value) - .disabled(accessDenied) // prevent use when no access - } footer: { - Text(""" - Add the Apple-Calendar complication to your watch or CarPlay \ - to see BG readings. Create a separate calendar (e.g. “Follow”) \ - — this view will **delete** events on the same calendar each time \ - it writes new readings. - """) - } + Form { + // ------------- Calendar write ------------- + Section { + Toggle("Save BG to Calendar", + isOn: $writeCalendarEvent.value) + .disabled(accessDenied) // prevent use when no access + } footer: { + Text(""" + Add the Apple-Calendar complication to your watch or CarPlay \ + to see BG readings. Create a separate calendar (e.g. “Follow”) \ + — this view will **delete** events on the same calendar each time \ + it writes new readings. + """) + } - // ------------- Access / calendar picker ------------- - if accessDenied { - Text("Calendar access denied") - .foregroundColor(.red) - } else { - if !calendars.isEmpty { - Picker("Calendar", - selection: $calendarIdentifier.value) - { - ForEach(calendars, id: \.calendarIdentifier) { cal in - Text(cal.title).tag(cal.calendarIdentifier) - } + // ------------- Access / calendar picker ------------- + if accessDenied { + Text("Calendar access denied") + .foregroundColor(.red) + } else { + if !calendars.isEmpty { + Picker("Calendar", + selection: $calendarIdentifier.value) + { + ForEach(calendars, id: \.calendarIdentifier) { cal in + Text(cal.title).tag(cal.calendarIdentifier) } } } + } - // ------------- Template lines ------------- - Section("Calendar Text") { - TextField("Line 1", text: $watchLine1.value) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) + // ------------- Template lines ------------- + Section("Calendar Text") { + TextField("Line 1", text: $watchLine1.value) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) - TextField("Line 2", text: $watchLine2.value) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - } + TextField("Line 2", text: $watchLine2.value) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + } - // ------------- Variable cheat-sheet ------------- - Section("Available Variables") { - ForEach(variableDescriptions, id: \.self) { desc in - Text(desc) - } + // ------------- Variable cheat-sheet ------------- + Section("Available Variables") { + ForEach(variableDescriptions, id: \.self) { desc in + Text(desc) } } - .task { // runs once on appear - await requestCalendarAccessAndLoad() - } + } + .task { // runs once on appear + await requestCalendarAccessAndLoad() } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Calendar", displayMode: .inline) diff --git a/LoopFollow/Settings/ContactSettingsView.swift b/LoopFollow/Settings/ContactSettingsView.swift index 111668fae..5ace358db 100644 --- a/LoopFollow/Settings/ContactSettingsView.swift +++ b/LoopFollow/Settings/ContactSettingsView.swift @@ -12,117 +12,115 @@ struct ContactSettingsView: View { @State private var alertMessage: String = "" var body: some View { - NavigationView { - Form { - Section(header: Text("Contact Integration")) { - Text("Add the contact named '\(viewModel.contactName)' to your watch face to show the current BG value in real time. Make sure to give the app full access to Contacts when prompted.") + Form { + Section(header: Text("Contact Integration")) { + Text("Add the contact named '\(viewModel.contactName)' to your watch face to show the current BG value in real time. Make sure to give the app full access to Contacts when prompted.") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.vertical, 4) + + Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled) + .toggleStyle(SwitchToggleStyle()) + .onChange(of: viewModel.contactEnabled) { isEnabled in + if isEnabled { + requestContactAccess() + } + } + } + + if viewModel.contactEnabled { + Section(header: Text("Color Options")) { + Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.") .font(.footnote) .foregroundColor(.secondary) .padding(.vertical, 4) - Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled) - .toggleStyle(SwitchToggleStyle()) - .onChange(of: viewModel.contactEnabled) { isEnabled in - if isEnabled { - requestContactAccess() - } + Picker("Background Color", selection: $viewModel.contactBackgroundColor) { + ForEach(ContactColorOption.allCases, id: \.rawValue) { option in + Text(option.rawValue.capitalized).tag(option.rawValue) } - } + } - if viewModel.contactEnabled { - Section(header: Text("Color Options")) { - Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.") - .font(.footnote) - .foregroundColor(.secondary) - .padding(.vertical, 4) + Picker("Color Mode", selection: $viewModel.contactColorMode) { + ForEach(ContactColorMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } - Picker("Background Color", selection: $viewModel.contactBackgroundColor) { + if viewModel.contactColorMode == .staticColor { + Picker("Text Color", selection: $viewModel.contactTextColor) { ForEach(ContactColorOption.allCases, id: \.rawValue) { option in Text(option.rawValue.capitalized).tag(option.rawValue) } } - - Picker("Color Mode", selection: $viewModel.contactColorMode) { - ForEach(ContactColorMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - - if viewModel.contactColorMode == .staticColor { - Picker("Text Color", selection: $viewModel.contactTextColor) { - ForEach(ContactColorOption.allCases, id: \.rawValue) { option in - Text(option.rawValue.capitalized).tag(option.rawValue) - } - } - } else { - Text("Dynamic mode colors text based on BG range: Green (in range), Yellow (high), Red (low)") - .font(.footnote) - .foregroundColor(.secondary) - } - } - - Section(header: Text("Additional Information")) { - Text("To see your trend, delta, or IOB, include them in another contact or create separate contacts. When using 'Include', select which contact to add the value to.") + } else { + Text("Dynamic mode colors text based on BG range: Green (in range), Yellow (high), Red (low)") .font(.footnote) .foregroundColor(.secondary) - .padding(.vertical, 4) + } + } - Text("Trend") - .font(.subheadline) - Picker("Show Trend", selection: $viewModel.contactTrend) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Section(header: Text("Additional Information")) { + Text("To see your trend, delta, or IOB, include them in another contact or create separate contacts. When using 'Include', select which contact to add the value to.") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.vertical, 4) + + Text("Trend") + .font(.subheadline) + Picker("Show Trend", selection: $viewModel.contactTrend) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) + } + .pickerStyle(SegmentedPickerStyle()) - if viewModel.contactTrend == .include { - Picker("Include Trend in", selection: $viewModel.contactTrendTarget) { - ForEach(viewModel.availableTargets(for: .Trend), id: \.self) { target in - Text(target.rawValue).tag(target) - } + if viewModel.contactTrend == .include { + Picker("Include Trend in", selection: $viewModel.contactTrendTarget) { + ForEach(viewModel.availableTargets(for: .Trend), id: \.self) { target in + Text(target.rawValue).tag(target) } } + } - Text("Delta") - .font(.subheadline) - Picker("Show Delta", selection: $viewModel.contactDelta) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Text("Delta") + .font(.subheadline) + Picker("Show Delta", selection: $viewModel.contactDelta) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) + } + .pickerStyle(SegmentedPickerStyle()) - if viewModel.contactDelta == .include { - Picker("Include Delta in", selection: $viewModel.contactDeltaTarget) { - ForEach(viewModel.availableTargets(for: .Delta), id: \.self) { target in - Text(target.rawValue).tag(target) - } + if viewModel.contactDelta == .include { + Picker("Include Delta in", selection: $viewModel.contactDeltaTarget) { + ForEach(viewModel.availableTargets(for: .Delta), id: \.self) { target in + Text(target.rawValue).tag(target) } } + } - Text("IOB") - .font(.subheadline) - Picker("Show IOB", selection: $viewModel.contactIOB) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Text("IOB") + .font(.subheadline) + Picker("Show IOB", selection: $viewModel.contactIOB) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) + } + .pickerStyle(SegmentedPickerStyle()) - if viewModel.contactIOB == .include { - Picker("Include IOB in", selection: $viewModel.contactIOBTarget) { - ForEach(viewModel.availableTargets(for: .IOB), id: \.self) { target in - Text(target.rawValue).tag(target) - } + if viewModel.contactIOB == .include { + Picker("Include IOB in", selection: $viewModel.contactIOBTarget) { + ForEach(viewModel.availableTargets(for: .IOB), id: \.self) { target in + Text(target.rawValue).tag(target) } } } } } - .alert(isPresented: $showAlert) { - Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) - } + } + .alert(isPresented: $showAlert) { + Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Contact", displayMode: .inline) diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 9949f3b93..0ac3acc69 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -32,118 +32,116 @@ struct GeneralSettingsView: View { @ObservedObject var telemetryEnabled = Storage.shared.telemetryEnabled var body: some View { - NavigationView { - Form { - Section("App Settings") { - Toggle("Display App Badge", isOn: $appBadge.value) - Toggle("Persistent Notification", isOn: $persistentNotification.value) - } + Form { + Section("App Settings") { + Toggle("Display App Badge", isOn: $appBadge.value) + Toggle("Persistent Notification", isOn: $persistentNotification.value) + } - Section("Display") { - Picker("Appearance", selection: $appearanceMode.value) { - ForEach(AppearanceMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } + Section("Display") { + Picker("Appearance", selection: $appearanceMode.value) { + ForEach(AppearanceMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) } - Toggle("Display Stats", isOn: $showStats.value) - Toggle("Display Small Graph", isOn: $showSmallGraph.value) - Toggle("Color BG Text", isOn: $colorBGText.value) - Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) - Toggle("Show Display Name", isOn: $showDisplayName.value) - Toggle("Snoozer emoji", isOn: $snoozerEmoji.value) - Toggle("Force portrait mode", isOn: $forcePortraitMode.value) - .onChange(of: forcePortraitMode.value) { _ in - let window = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first - - window?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() - } } + Toggle("Display Stats", isOn: $showStats.value) + Toggle("Display Small Graph", isOn: $showSmallGraph.value) + Toggle("Color BG Text", isOn: $colorBGText.value) + Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) + Toggle("Show Display Name", isOn: $showDisplayName.value) + Toggle("Snoozer emoji", isOn: $snoozerEmoji.value) + Toggle("Force portrait mode", isOn: $forcePortraitMode.value) + .onChange(of: forcePortraitMode.value) { _ in + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first + + window?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + } - Section("Time Zone") { - Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value) - .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() } + Section("Time Zone") { + Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value) + .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() } - if graphTimeZoneEnabled.value { - Picker("Time Zone", selection: $graphTimeZoneIdentifier.value) { - ForEach(Self.sortedTimeZones, id: \.identifier) { tz in - Text(Self.timeZoneLabel(tz)).tag(tz.identifier) - } + if graphTimeZoneEnabled.value { + Picker("Time Zone", selection: $graphTimeZoneIdentifier.value) { + ForEach(Self.sortedTimeZones, id: \.identifier) { tz in + Text(Self.timeZoneLabel(tz)).tag(tz.identifier) } - .onChange(of: graphTimeZoneIdentifier.value) { _ in markChartSettingsDirty() } } + .onChange(of: graphTimeZoneIdentifier.value) { _ in markChartSettingsDirty() } } + } - Section("Speak BG") { - Toggle("Speak BG", isOn: $speakBG.value.animation()) + Section("Speak BG") { + Toggle("Speak BG", isOn: $speakBG.value.animation()) - if speakBG.value { - Picker("Language", selection: $speakLanguage.value) { - Text("English").tag("en") - Text("French").tag("fr") - Text("Italian").tag("it") - Text("Slovak").tag("sk") - Text("Swedish").tag("sv") - } + if speakBG.value { + Picker("Language", selection: $speakLanguage.value) { + Text("English").tag("en") + Text("French").tag("fr") + Text("Italian").tag("it") + Text("Slovak").tag("sk") + Text("Swedish").tag("sv") + } - Toggle("Always", isOn: $speakBGAlways.value.animation()) + Toggle("Always", isOn: $speakBGAlways.value.animation()) - if !speakBGAlways.value { - Toggle("Low", isOn: $speakLowBG.value.animation()) - .onChange(of: speakLowBG.value) { newValue in - if newValue { - speakProactiveLowBG.value = false - } + if !speakBGAlways.value { + Toggle("Low", isOn: $speakLowBG.value.animation()) + .onChange(of: speakLowBG.value) { newValue in + if newValue { + speakProactiveLowBG.value = false } + } - Toggle("Proactive Low", isOn: $speakProactiveLowBG.value.animation()) - .onChange(of: speakProactiveLowBG.value) { newValue in - if newValue { - speakLowBG.value = false - } + Toggle("Proactive Low", isOn: $speakProactiveLowBG.value.animation()) + .onChange(of: speakProactiveLowBG.value) { newValue in + if newValue { + speakLowBG.value = false } - - if speakLowBG.value || speakProactiveLowBG.value { - BGPicker( - title: "Low BG Limit", - range: 40 ... 108, - value: $speakLowBGLimit.value - ) } - if speakProactiveLowBG.value { - BGPicker( - title: "Fast Drop Delta", - range: 3 ... 20, - value: $speakFastDropDelta.value - ) - } + if speakLowBG.value || speakProactiveLowBG.value { + BGPicker( + title: "Low BG Limit", + range: 40 ... 108, + value: $speakLowBGLimit.value + ) + } - Toggle("High", isOn: $speakHighBG.value.animation()) + if speakProactiveLowBG.value { + BGPicker( + title: "Fast Drop Delta", + range: 3 ... 20, + value: $speakFastDropDelta.value + ) + } - if speakHighBG.value { - BGPicker( - title: "High BG Limit", - range: 140 ... 300, - value: $speakHighBGLimit.value - ) - } + Toggle("High", isOn: $speakHighBG.value.animation()) + + if speakHighBG.value { + BGPicker( + title: "High BG Limit", + range: 140 ... 300, + value: $speakHighBGLimit.value + ) } } } + } - Section("Diagnostics") { - Toggle("Send anonymous usage stats", isOn: $telemetryEnabled.value) - .onChange(of: telemetryEnabled.value) { newValue in - if newValue { - TelemetryClient.shared.scheduleRecurring() - } + Section("Diagnostics") { + Toggle("Send anonymous usage stats", isOn: $telemetryEnabled.value) + .onChange(of: telemetryEnabled.value) { newValue in + if newValue { + TelemetryClient.shared.scheduleRecurring() } - NavigationLink("What's sent") { TelemetryPreviewView() } - NavigationLink("Privacy") { TelemetryPrivacyView() } - } + } + NavigationLink("What's sent") { TelemetryPreviewView() } + NavigationLink("Privacy") { TelemetryPrivacyView() } } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 534e00698..cd0fd40a9 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -24,106 +24,104 @@ struct GraphSettingsView: View { private var nightscoutEnabled: Bool { IsNightscoutEnabled() } var body: some View { - NavigationView { - Form { - // ── Graph Display ──────────────────────────────────────────── - Section("Graph Display") { - Toggle("Display Dots", isOn: $showDots.value) - .onChange(of: showDots.value) { _ in markDirty() } + Form { + // ── Graph Display ──────────────────────────────────────────── + Section("Graph Display") { + Toggle("Display Dots", isOn: $showDots.value) + .onChange(of: showDots.value) { _ in markDirty() } - Toggle("Display Lines", isOn: $showLines.value) - .onChange(of: showLines.value) { _ in markDirty() } + Toggle("Display Lines", isOn: $showLines.value) + .onChange(of: showLines.value) { _ in markDirty() } - if nightscoutEnabled { - Toggle("Show DIA Lines", isOn: $showDIALines.value) - .onChange(of: showDIALines.value) { _ in markDirty() } - - Toggle("Show −30 min Line", isOn: $show30MinLine.value) - .onChange(of: show30MinLine.value) { _ in markDirty() } + if nightscoutEnabled { + Toggle("Show DIA Lines", isOn: $showDIALines.value) + .onChange(of: showDIALines.value) { _ in markDirty() } - Toggle("Show −90 min Line", isOn: $show90MinLine.value) - .onChange(of: show90MinLine.value) { _ in markDirty() } - } + Toggle("Show −30 min Line", isOn: $show30MinLine.value) + .onChange(of: show30MinLine.value) { _ in markDirty() } - Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) - .onChange(of: showMidnightLines.value) { _ in markDirty() } + Toggle("Show −90 min Line", isOn: $show90MinLine.value) + .onChange(of: show90MinLine.value) { _ in markDirty() } } - // ── Treatments ─────────────────────────────────────────────── - if nightscoutEnabled { - Section("Treatments") { - Toggle("Show Carb/Bolus Values", isOn: $showValues.value) - Toggle("Show Carb Absorption", isOn: $showAbsorption.value) - Toggle("Treatments on Small Graph", - isOn: $smallGraphTreatments.value) - } + Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) + .onChange(of: showMidnightLines.value) { _ in markDirty() } + } + + // ── Treatments ─────────────────────────────────────────────── + if nightscoutEnabled { + Section("Treatments") { + Toggle("Show Carb/Bolus Values", isOn: $showValues.value) + Toggle("Show Carb Absorption", isOn: $showAbsorption.value) + Toggle("Treatments on Small Graph", + isOn: $smallGraphTreatments.value) } + } + + // ── Small Graph ────────────────────────────────────────────── + Section("Small Graph") { + SettingsStepperRow( + title: "Height", + range: 40 ... 80, + step: 5, + value: $smallGraphHeight.value, + format: { "\(Int($0)) pt" } + ) + .onChange(of: smallGraphHeight.value) { _ in markDirty() } + } - // ── Small Graph ────────────────────────────────────────────── - Section("Small Graph") { + // ── Prediction ─────────────────────────────────────────────── + if nightscoutEnabled { + Section("Prediction") { SettingsStepperRow( - title: "Height", - range: 40 ... 80, - step: 5, - value: $smallGraphHeight.value, - format: { "\(Int($0)) pt" } + title: "Hours of Prediction", + range: 0 ... 6, + step: 0.25, + value: $predictionToLoad.value, + format: { "\($0.localized(maxFractionDigits: 2)) h" } ) - .onChange(of: smallGraphHeight.value) { _ in markDirty() } - } - // ── Prediction ─────────────────────────────────────────────── - if nightscoutEnabled { - Section("Prediction") { - SettingsStepperRow( - title: "Hours of Prediction", - range: 0 ... 6, - step: 0.25, - value: $predictionToLoad.value, - format: { "\($0.localized(maxFractionDigits: 2)) h" } - ) - - if Storage.shared.device.value != "Loop" { - Picker("Prediction Style", selection: $predictionDisplayType.value) { - ForEach(PredictionDisplayType.allCases, id: \.self) { type in - Text(type.displayName).tag(type) - } + if Storage.shared.device.value != "Loop" { + Picker("Prediction Style", selection: $predictionDisplayType.value) { + ForEach(PredictionDisplayType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) } - .onChange(of: predictionDisplayType.value) { _ in markDirty() } } + .onChange(of: predictionDisplayType.value) { _ in markDirty() } } } + } - // ── Basal / BG scale ───────────────────────────────────────── - if nightscoutEnabled { - Section("Basal / BG Scale") { - SettingsStepperRow( - title: "Min Basal", - range: 0.5 ... 20, - step: 0.5, - value: $minBasalScale.value, - format: { "\($0.localized(maxFractionDigits: 1)) U/h" } - ) - - BGPicker( - title: "Min BG Scale", - range: 40 ... 400, - value: $minBGScale.value - ) - .onChange(of: minBGScale.value) { _ in markDirty() } - } + // ── Basal / BG scale ───────────────────────────────────────── + if nightscoutEnabled { + Section("Basal / BG Scale") { + SettingsStepperRow( + title: "Min Basal", + range: 0.5 ... 20, + step: 0.5, + value: $minBasalScale.value, + format: { "\($0.localized(maxFractionDigits: 1)) U/h" } + ) + + BGPicker( + title: "Min BG Scale", + range: 40 ... 400, + value: $minBGScale.value + ) + .onChange(of: minBGScale.value) { _ in markDirty() } } + } - // ── History window ─────────────────────────────────────────── - if nightscoutEnabled { - Section("History") { - SettingsStepperRow( - title: "Show Days Back", - range: 1 ... 4, - step: 1, - value: $downloadDays.value, - format: { "\(Int($0)) d" } - ) - } + // ── History window ─────────────────────────────────────────── + if nightscoutEnabled { + Section("History") { + SettingsStepperRow( + title: "Show Days Back", + range: 1 ... 4, + step: 1, + value: $downloadDays.value, + format: { "\(Int($0)) d" } + ) } } } diff --git a/LoopFollow/Settings/HomeContentView.swift b/LoopFollow/Settings/HomeContentView.swift index fe88cb569..4088e8061 100644 --- a/LoopFollow/Settings/HomeContentView.swift +++ b/LoopFollow/Settings/HomeContentView.swift @@ -6,36 +6,36 @@ import UIKit /// A SwiftUI wrapper around MainViewController that displays the full Home screen. /// This can be used both in the tab bar and as a modal from the Menu. -struct HomeContentView: UIViewControllerRepresentable { +struct HomeContentView: View { let isModal: Bool init(isModal: Bool = false) { self.isModal = isModal } - func makeUIViewController(context _: Context) -> UIViewController { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - - // Get the MainViewController from storyboard - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { - let fallbackVC = UIViewController() - fallbackVC.view.backgroundColor = .systemBackground - let label = UILabel() - label.text = "Unable to load Home screen" - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - fallbackVC.view.addSubview(label) - NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: fallbackVC.view.centerXAnchor), - label.centerYAnchor.constraint(equalTo: fallbackVC.view.centerYAnchor), - ]) - return fallbackVC - } + var body: some View { + MainViewControllerRepresentable() + // Home has no text input, yet iOS sometimes replays a stale keyboard + // frame when the app returns to the foreground, which squeezes the + // whole screen up by a keyboard's height until a rotation forces the + // safe area to recompute. Opting out of keyboard avoidance prevents it. + .ignoresSafeArea(.keyboard) + } +} +private struct MainViewControllerRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context _: Context) -> UIViewController { + // Reuse the single long-lived instance rather than creating a new one, + // so there is exactly one data pipeline and MainViewController.shared is + // never displaced. bootstrap() is a no-op if it already exists. + MainViewController.bootstrap() + let mainVC = MainViewController.shared! + // Detach from any previous SwiftUI host (e.g. after a Menu push was + // popped and is now being re-pushed) before this representable embeds it. + mainVC.willMove(toParent: nil) + mainVC.removeFromParent() + mainVC.view.removeFromSuperview() mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - mainVC.isPresentedAsModal = isModal - return mainVC } @@ -50,7 +50,7 @@ struct HomeModalView: View { @Environment(\.dismiss) private var dismiss var body: some View { - NavigationView { + NavigationStack { HomeContentView(isModal: true) .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) diff --git a/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift index adf334884..6082b2187 100644 --- a/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift +++ b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift @@ -9,67 +9,65 @@ struct ImportExportSettingsView: View { @StateObject private var viewModel = ImportExportSettingsViewModel() var body: some View { - NavigationView { - List { - // MARK: - Import Section + List { + // MARK: - Import Section - Section("Import Settings") { - Button(action: { - viewModel.isShowingQRCodeScanner = true - }) { - HStack { - Image(systemName: "qrcode.viewfinder") - .foregroundColor(.blue) - Text("Scan QR Code to Import Settings") - } + Section("Import Settings") { + Button(action: { + viewModel.isShowingQRCodeScanner = true + }) { + HStack { + Image(systemName: "qrcode.viewfinder") + .foregroundColor(.blue) + Text("Scan QR Code to Import Settings") } - .buttonStyle(.plain) } + .buttonStyle(.plain) + } - // MARK: - Export Section + // MARK: - Export Section - Section("Export Settings To QR Code") { - ForEach(ImportExportSettingsViewModel.ExportType.allCases, id: \.self) { exportType in - Button(action: { - if exportType == .alarms { - viewModel.showAlarmSelection() - } else { - viewModel.exportType = exportType - if let qrString = viewModel.generateQRCodeForExport() { - viewModel.qrCodeString = qrString - viewModel.isShowingQRCodeDisplay = true - } - } - }) { - HStack { - Image(systemName: exportType.icon) - .foregroundColor(.blue) - Text("Export \(exportType.rawValue)") - Spacer() - Image(systemName: exportType == .alarms ? "list.bullet" : "qrcode") - .foregroundColor(.secondary) + Section("Export Settings To QR Code") { + ForEach(ImportExportSettingsViewModel.ExportType.allCases, id: \.self) { exportType in + Button(action: { + if exportType == .alarms { + viewModel.showAlarmSelection() + } else { + viewModel.exportType = exportType + if let qrString = viewModel.generateQRCodeForExport() { + viewModel.qrCodeString = qrString + viewModel.isShowingQRCodeDisplay = true } } - .buttonStyle(.plain) + }) { + HStack { + Image(systemName: exportType.icon) + .foregroundColor(.blue) + Text("Export \(exportType.rawValue)") + Spacer() + Image(systemName: exportType == .alarms ? "list.bullet" : "qrcode") + .foregroundColor(.secondary) + } } + .buttonStyle(.plain) } + } - // MARK: - Status Message + // MARK: - Status Message - if !viewModel.qrCodeErrorMessage.isEmpty { - Section { - let isSuccess = viewModel.qrCodeErrorMessage.contains("successfully") || viewModel.qrCodeErrorMessage.contains("Successfully imported") - let displayText = isSuccess ? "✅ \(viewModel.qrCodeErrorMessage)" : viewModel.qrCodeErrorMessage + if !viewModel.qrCodeErrorMessage.isEmpty { + Section { + let isSuccess = viewModel.qrCodeErrorMessage.contains("successfully") || viewModel.qrCodeErrorMessage.contains("Successfully imported") + let displayText = isSuccess ? "✅ \(viewModel.qrCodeErrorMessage)" : viewModel.qrCodeErrorMessage - Text(displayText) - .foregroundColor(isSuccess ? .green : .red) - .font(.caption) - } + Text(displayText) + .foregroundColor(isSuccess ? .green : .red) + .font(.caption) } } - .navigationTitle("Import/Export Settings") - .navigationBarTitleDisplayMode(.inline) } + .navigationTitle("Import/Export Settings") + .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $viewModel.isShowingQRCodeScanner) { SimpleQRCodeScannerView { result in viewModel.handleQRCodeScanResult(result) diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 548cd0964..f354c51b6 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -5,138 +5,85 @@ import SwiftUI import UIKit struct SettingsMenuView: View { - // MARK: - Observed Objects - @ObservedObject private var nightscoutURL = Storage.shared.url - @ObservedObject private var settingsPath = Observable.shared.settingsPath - - // MARK: – Local state - - var onBack: (() -> Void)? - - // MARK: – Observed objects - - @ObservedObject private var url = Storage.shared.url - - // MARK: – Body var body: some View { - NavigationStack(path: $settingsPath.value) { - List { - dataSection - - Section("Display Settings") { - NavigationRow(title: "General", - icon: "gearshape") - { - settingsPath.value.append(Sheet.general) - } - - NavigationRow(title: "Graph", - icon: "chart.xyaxis.line") - { - settingsPath.value.append(Sheet.graph) - } - - if !nightscoutURL.value.isEmpty { - NavigationRow(title: "Information Display", - icon: "info.circle") - { - settingsPath.value.append(Sheet.infoDisplay) - } - } - NavigationRow(title: "Units and Metrics", - icon: "scalemass") - { - settingsPath.value.append(Sheet.units) - } - - NavigationRow(title: "Tabs", - icon: "rectangle.3.group") - { - settingsPath.value.append(Sheet.tabSettings) - } + List { + dataSection + + Section("Display Settings") { + NavigationRow(title: "General", + icon: "gearshape", + value: SettingsRoute.general) + NavigationRow(title: "Graph", + icon: "chart.xyaxis.line", + value: SettingsRoute.graph) + + if !nightscoutURL.value.isEmpty { + NavigationRow(title: "Information Display", + icon: "info.circle", + value: SettingsRoute.infoDisplay) } - Section("App Settings") { - NavigationRow(title: "Background Refresh", - icon: "arrow.clockwise") - { - settingsPath.value.append(Sheet.backgroundRefresh) - } - - NavigationRow(title: "Import/Export", - icon: "square.and.arrow.down") - { - settingsPath.value.append(Sheet.importExport) - } - - NavigationRow(title: "APN", - icon: "bell.and.waves.left.and.right") - { - settingsPath.value.append(Sheet.apn) - } - - #if !targetEnvironment(macCatalyst) - NavigationRow(title: "Live Activity", - icon: "dot.radiowaves.left.and.right") - { - settingsPath.value.append(Sheet.liveActivity) - } - #endif + NavigationRow(title: "Units and Metrics", + icon: "scalemass", + value: SettingsRoute.units) - if !nightscoutURL.value.isEmpty { - NavigationRow(title: "Remote", - icon: "antenna.radiowaves.left.and.right") - { - settingsPath.value.append(Sheet.remote) - } - } - } + NavigationRow(title: "Tabs", + icon: "rectangle.3.group", + value: SettingsRoute.tabSettings) + } - Section("Alarms") { - NavigationRow(title: "Alarms", - icon: "bell.badge") - { - settingsPath.value.append(Sheet.alarmSettings) - } + Section("App Settings") { + NavigationRow(title: "Background Refresh", + icon: "arrow.clockwise", + value: SettingsRoute.backgroundRefresh) + + NavigationRow(title: "Import/Export", + icon: "square.and.arrow.down", + value: SettingsRoute.importExport) + + NavigationRow(title: "APN", + icon: "bell.and.waves.left.and.right", + value: SettingsRoute.apn) + + #if !targetEnvironment(macCatalyst) + NavigationRow(title: "Live Activity", + icon: "dot.radiowaves.left.and.right", + value: SettingsRoute.liveActivity) + #endif + + if !nightscoutURL.value.isEmpty { + NavigationRow(title: "Remote", + icon: "antenna.radiowaves.left.and.right", + value: SettingsRoute.remote) } + } - Section("Integrations") { - NavigationRow(title: "Calendar", - icon: "calendar") - { - settingsPath.value.append(Sheet.calendar) - } + Section("Alarms") { + NavigationRow(title: "Alarms", + icon: "bell.badge", + value: SettingsRoute.alarmSettings) + } - NavigationRow(title: "Contact", - icon: "person.circle") - { - settingsPath.value.append(Sheet.contact) - } - } + Section("Integrations") { + NavigationRow(title: "Calendar", + icon: "calendar", + value: SettingsRoute.calendar) - Section("Advanced Settings") { - NavigationRow(title: "Advanced", - icon: "exclamationmark.shield") - { - settingsPath.value.append(Sheet.advanced) - } - } + NavigationRow(title: "Contact", + icon: "person.circle", + value: SettingsRoute.contact) } - .navigationTitle("Settings") - .navigationBarTitleDisplayMode(.large) - .navigationDestination(for: Sheet.self) { $0.destination } - .toolbar { - if let onBack { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: onBack) { - Image(systemName: "chevron.left") - } - } - } + + Section("Advanced Settings") { + NavigationRow(title: "Advanced", + icon: "exclamationmark.shield", + value: SettingsRoute.advanced) } } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.large) } // MARK: – Section builders @@ -145,23 +92,20 @@ struct SettingsMenuView: View { private var dataSection: some View { Section("Data Settings") { NavigationRow(title: "Nightscout", - icon: "network") - { - settingsPath.value.append(Sheet.nightscout) - } + icon: "network", + value: SettingsRoute.nightscout) NavigationRow(title: "Dexcom", - icon: "sensor.tag.radiowaves.forward") - { - settingsPath.value.append(Sheet.dexcom) - } + icon: "sensor.tag.radiowaves.forward", + value: SettingsRoute.dexcom) } } } // MARK: – Sheet routing -private enum Sheet: Hashable, Identifiable { +enum SettingsRoute: Hashable, Identifiable { + case settings case units case nightscout, dexcom case backgroundRefresh @@ -184,6 +128,7 @@ private enum Sheet: Hashable, Identifiable { @ViewBuilder var destination: some View { switch self { + case .settings: SettingsMenuView() case .units: UnitsSettingsView() case .nightscout: NightscoutSettingsView(viewModel: .init()) case .dexcom: DexcomSettingsView(viewModel: .init()) @@ -226,37 +171,7 @@ struct AggregatedStatsViewWrapper: View { } private func getMainViewController() -> MainViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController - else { - return nil - } - - if let mainVC = rootVC as? MainViewController { - return mainVC - } - - if let navVC = rootVC as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - - if let tabVC = rootVC as? UITabBarController { - for vc in tabVC.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - } - - return nil + MainViewController.shared } } @@ -266,7 +181,14 @@ import UIKit extension UIApplication { var topMost: UIViewController? { - guard var top = keyWindow?.rootViewController else { return nil } + // `keyWindow` is deprecated and returns nil on Mac Catalyst / multi-window iPad. + // Walk connected scenes instead and prefer the foreground-active one. + let windowScenes = connectedScenes.compactMap { $0 as? UIWindowScene } + let activeScene = windowScenes.first { $0.activationState == .foregroundActive } + ?? windowScenes.first + let rootVC = activeScene?.windows.first(where: \.isKeyWindow)?.rootViewController + ?? activeScene?.windows.first?.rootViewController + guard var top = rootVC else { return nil } while let presented = top.presentedViewController { top = presented } diff --git a/LoopFollow/Snoozer/SnoozerViewController.swift b/LoopFollow/Snoozer/SnoozerViewController.swift deleted file mode 100644 index ea63d8e0f..000000000 --- a/LoopFollow/Snoozer/SnoozerViewController.swift +++ /dev/null @@ -1,65 +0,0 @@ -// LoopFollow -// SnoozerViewController.swift - -import Combine -import SwiftUI -import UIKit - -class SnoozerViewController: UIViewController { - private var hostingController: UIHostingController? - private var cancellables = Set() - - @State private var snoozeMinutes = 15 - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - - let snoozerView = SnoozerView() - - let hosting = UIHostingController(rootView: snoozerView) - hostingController = hosting - - // Apply initial appearance - hosting.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.hostingController?.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.hostingController?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - - addChild(hosting) - view.addSubview(hosting.view) - hosting.view.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hosting.view.topAnchor.constraint(equalTo: view.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - hosting.didMove(toParent: self) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - hostingController?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } -} diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index e6b49c45b..baf5f1571 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -4,11 +4,17 @@ import Foundation class StatsDataFetcher { - weak var mainViewController: MainViewController? + /// See StatsDataService.mainViewController — the injected reference can be + /// nil at cold launch, so fall back to the shared engine. + private weak var injectedMainViewController: MainViewController? + var mainViewController: MainViewController? { + injectedMainViewController ?? MainViewController.shared + } + weak var dataService: StatsDataService? init(mainViewController: MainViewController?) { - self.mainViewController = mainViewController + injectedMainViewController = mainViewController } func fetchBGData(days: Int, completion: @escaping () -> Void) { diff --git a/LoopFollow/Stats/StatsDataService.swift b/LoopFollow/Stats/StatsDataService.swift index 4aa5b7c7f..a9f560b29 100644 --- a/LoopFollow/Stats/StatsDataService.swift +++ b/LoopFollow/Stats/StatsDataService.swift @@ -4,7 +4,13 @@ import Foundation class StatsDataService { - weak var mainViewController: MainViewController? + /// The injected reference can be nil when the stats UI is built before + /// MainViewController.bootstrap() has run (cold launch with Statistics as + /// the selected tab), so resolve lazily and fall back to the shared engine. + private weak var injectedMainViewController: MainViewController? + var mainViewController: MainViewController? { + injectedMainViewController ?? MainViewController.shared + } var daysToAnalyze: Int = 14 // Keep for backward compatibility var startDate: Date = dateTimeUtils.displayCalendar().date(byAdding: .day, value: -14, to: Date()) ?? Date() @@ -23,7 +29,7 @@ class StatsDataService { } init(mainViewController: MainViewController?) { - self.mainViewController = mainViewController + injectedMainViewController = mainViewController dataFetcher = StatsDataFetcher(mainViewController: mainViewController) dataFetcher.dataService = self } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index f32887523..4128918db 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -24,6 +24,12 @@ class Observable { var deltaText = ObservableValue(default: "+0") var iobText = ObservableValue(default: "--") + var serverText = ObservableValue(default: "Server") + var loopStatusText = ObservableValue(default: "") + var loopStatusColor = ObservableValue(default: .primary) + var predictionText = ObservableValue(default: "") + var predictionColor = ObservableValue(default: .purple) + var currentAlarm = ObservableValue(default: nil) var alarmSoundPlaying = ObservableValue(default: false) @@ -38,13 +44,14 @@ class Observable { var pumpBatteryLevel = ObservableValue(default: nil) var enactedOrSuggested = ObservableValue(default: nil) - var settingsPath = ObservableValue(default: NavigationPath()) - var lastSentTOTP = ObservableValue(default: nil) var loopFollowDeviceToken = ObservableValue(default: "") var isNotLooping = ObservableValue(default: false) + /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs + var selectedTabIndex = ObservableValue(default: 0) + private init() {} } diff --git a/LoopFollow/Task/MinAgoTask.swift b/LoopFollow/Task/MinAgoTask.swift index 0ea011cee..0ba964d99 100644 --- a/LoopFollow/Task/MinAgoTask.swift +++ b/LoopFollow/Task/MinAgoTask.swift @@ -2,7 +2,6 @@ // MinAgoTask.swift import Foundation -import UIKit extension MainViewController { func scheduleMinAgoTask(initialDelay: TimeInterval = 1.0) { @@ -15,9 +14,7 @@ extension MainViewController { func minAgoTaskAction() { guard bgData.count > 0, let lastBG = bgData.last else { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.MinAgoText.text = "" + DispatchQueue.main.async { Observable.shared.minAgoText.value = "" Observable.shared.bgText.value = "" } @@ -46,9 +43,7 @@ extension MainViewController { // Update UI only if the display text has changed if minAgoDisplayText != Observable.shared.minAgoText.value { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.MinAgoText.text = minAgoDisplayText + DispatchQueue.main.async { Observable.shared.minAgoText.value = minAgoDisplayText } } @@ -56,19 +51,12 @@ extension MainViewController { let deltaTime = secondsAgo / 60 Observable.shared.bgStale.value = deltaTime >= 12 - // Apply strikethrough to BGText based on the staleness of the data - // Also clear badge if bgvalue is stale - let bgTextStr = BGText.text ?? "" - let attributeString = NSMutableAttributedString(string: bgTextStr) - attributeString.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: attributeString.length)) - if Observable.shared.bgStale.value { // Data is stale - attributeString.addAttribute(.strikethroughColor, value: UIColor.systemRed, range: NSRange(location: 0, length: attributeString.length)) + // Update badge based on staleness + if Observable.shared.bgStale.value { updateBadge(val: 0) - } else { // Data is fresh - attributeString.addAttribute(.strikethroughColor, value: UIColor.clear, range: NSRange(location: 0, length: attributeString.length)) + } else { updateBadge(val: Observable.shared.bg.value ?? 0) } - BGText.attributedText = attributeString // Determine the next run interval based on the current state let nextUpdateInterval: TimeInterval diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index da6d97182..f1b1c7595 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -826,25 +826,7 @@ class TreatmentDetailViewModel: ObservableObject { } private func getMainViewController() -> MainViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let tabBarController = window.rootViewController as? UITabBarController - else { - return nil - } - - for vc in tabBarController.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - - return nil + MainViewController.shared } } @@ -1417,26 +1399,7 @@ class TreatmentsViewModel: ObservableObject { } private func getMainViewController() -> MainViewController? { - // Try to find MainViewController in the app's window hierarchy - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let tabBarController = window.rootViewController as? UITabBarController - else { - return nil - } - - for vc in tabBarController.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - - return nil + MainViewController.shared } } diff --git a/LoopFollow/ViewControllers/AlarmViewController.swift b/LoopFollow/ViewControllers/AlarmViewController.swift deleted file mode 100644 index 1b3c4d60b..000000000 --- a/LoopFollow/ViewControllers/AlarmViewController.swift +++ /dev/null @@ -1,60 +0,0 @@ -// LoopFollow -// AlarmViewController.swift - -import Combine -import SwiftUI -import UIKit - -class AlarmViewController: UIViewController { - private var hostingController: UIHostingController! - private var cancellables = Set() - - override func viewDidLoad() { - super.viewDidLoad() - - let alarmsView = AlarmsContainerView() - hostingController = UIHostingController(rootView: alarmsView) - - // Apply initial appearance - hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.hostingController.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - - addChild(hostingController) - view.addSubview(hostingController.view) - - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - hostingController.didMove(toParent: self) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } -} diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift new file mode 100644 index 000000000..ae626fa47 --- /dev/null +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -0,0 +1,74 @@ +// LoopFollow +// BGDisplayView.swift + +import SwiftUI + +struct BGDisplayView: View { + @ObservedObject var serverText = Observable.shared.serverText + @ObservedObject var bgText = Observable.shared.bgText + @ObservedObject var bgTextColor = Observable.shared.bgTextColor + @ObservedObject var bgStale = Observable.shared.bgStale + @ObservedObject var directionText = Observable.shared.directionText + @ObservedObject var deltaText = Observable.shared.deltaText + @ObservedObject var minAgoText = Observable.shared.minAgoText + @ObservedObject var loopStatusText = Observable.shared.loopStatusText + @ObservedObject var loopStatusColor = Observable.shared.loopStatusColor + @ObservedObject var predictionText = Observable.shared.predictionText + @ObservedObject var predictionColor = Observable.shared.predictionColor + @ObservedObject var isNotLooping = Observable.shared.isNotLooping + + var onRefresh: (() -> Void)? + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Text(serverText.value) + .font(.system(size: 13)) + + Text(bgText.value) + .font(.system(size: 85, weight: .black)) + .foregroundColor(bgTextColor.value) + .strikethrough( + bgStale.value, + pattern: .solid, + color: bgStale.value ? .red : .clear + ) + .frame(maxWidth: .infinity) + .lineLimit(1) + .minimumScaleFactor(0.5) + + HStack { + Text(directionText.value) + .font(.system(size: 60, weight: .black)) + Text(deltaText.value) + .font(.system(size: 32)) + } + .lineLimit(1) + .minimumScaleFactor(0.5) + + Text(minAgoText.value) + .font(.system(size: 17)) + + if isNotLooping.value { + Text(loopStatusText.value) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(loopStatusColor.value) + .frame(maxWidth: .infinity) + } else { + HStack { + Spacer() + Text(loopStatusText.value) + .foregroundColor(loopStatusColor.value) + Text(predictionText.value) + .foregroundColor(predictionColor.value) + Spacer() + } + .font(.system(size: 17)) + } + } + } + .refreshable { + onRefresh?() + } + } +} diff --git a/LoopFollow/ViewControllers/LineChartWrapper.swift b/LoopFollow/ViewControllers/LineChartWrapper.swift new file mode 100644 index 000000000..b08edf8a7 --- /dev/null +++ b/LoopFollow/ViewControllers/LineChartWrapper.swift @@ -0,0 +1,20 @@ +// LoopFollow +// LineChartWrapper.swift + +import Charts +import SwiftUI + +struct LineChartWrapper: UIViewRepresentable { + let chartView: LineChartView + + func makeUIView(context _: Context) -> LineChartView { + chartView + } + + func updateUIView(_: LineChartView, context _: Context) { + // Intentionally empty. MainViewController owns the chart and calls + // notifyDataSetChanged itself whenever it mutates the data; doing it + // here too would redo that work on every unrelated SwiftUI re-render + // of MainHomeView (e.g. the once-a-second minAgoText tick). + } +} diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift new file mode 100644 index 000000000..6e933bbb8 --- /dev/null +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -0,0 +1,70 @@ +// LoopFollow +// MainHomeView.swift + +import Charts +import SwiftUI + +struct MainHomeView: View { + let bgChart: LineChartView + let bgChartFull: LineChartView + @ObservedObject var infoManager: InfoManager + @ObservedObject var statsModel: StatsDisplayModel + + @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph + @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var hideInfoTable = Storage.shared.hideInfoTable + @ObservedObject var smallGraphHeight = Storage.shared.smallGraphHeight + @ObservedObject var url = Storage.shared.url + @ObservedObject var graphTimeZoneEnabled = Storage.shared.graphTimeZoneEnabled + @ObservedObject var graphTimeZoneIdentifier = Storage.shared.graphTimeZoneIdentifier + + var onRefresh: (() -> Void)? + var onStatsTap: (() -> Void)? + + private var timeZoneOverride: String? { + guard graphTimeZoneEnabled.value, + let tz = TimeZone(identifier: graphTimeZoneIdentifier.value) + else { return nil } + return tz.identifier + } + + private var isNightscoutEnabled: Bool { + !url.value.isEmpty + } + + var body: some View { + VStack(spacing: 8) { + // Top section: BG display + info table + HStack(spacing: 10) { + BGDisplayView(onRefresh: onRefresh) + + if isNightscoutEnabled && !hideInfoTable.value { + InfoTableView(infoManager: infoManager, timeZoneOverride: timeZoneOverride) + .frame(minWidth: 160, maxWidth: 250) + .overlay( + Rectangle() + .fill(Color(UIColor.darkGray)) + .frame(width: 2), + alignment: .leading + ) + } + } + .fixedSize(horizontal: false, vertical: true) + + // Main chart (fills remaining space) + LineChartWrapper(chartView: bgChart) + + // Small overview chart + if showSmallGraph.value { + LineChartWrapper(chartView: bgChartFull) + .frame(height: CGFloat(smallGraphHeight.value)) + } + + // Statistics + if showStats.value { + StatsDisplayView(model: statsModel, onTap: onStatsTap) + } + } + .padding(8) + } +} diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 6abea5ab4..f8515ce7b 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -23,34 +23,30 @@ private struct APNSCredentialSnapshot: Equatable { let lfKeyId: String } -class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { - var isPresentedAsModal: Bool = false - - @IBOutlet var BGText: UILabel! - @IBOutlet var DeltaText: UILabel! - @IBOutlet var DirectionText: UILabel! - @IBOutlet var BGChart: LineChartView! - @IBOutlet var BGChartFull: LineChartView! - @IBOutlet var MinAgoText: UILabel! - @IBOutlet var infoTable: UITableView! - @IBOutlet var Console: UITableViewCell! - @IBOutlet var DragBar: UIImageView! - @IBOutlet var PredictionLabel: UILabel! - @IBOutlet var LoopStatusLabel: UILabel! - @IBOutlet var statsPieChart: PieChartView! - @IBOutlet var statsLowPercent: UILabel! - @IBOutlet var statsInRangePercent: UILabel! - @IBOutlet var statsHighPercent: UILabel! - @IBOutlet var statsAvgBG: UILabel! - @IBOutlet var statsEstA1CTitle: UILabel! - @IBOutlet var statsEstA1C: UILabel! - @IBOutlet var statsStdDevTitle: UILabel! - @IBOutlet var statsStdDev: UILabel! - @IBOutlet var serverText: UILabel! - @IBOutlet var statsView: UIView! - @IBOutlet var smallGraphHeightConstraint: NSLayoutConstraint! - var refreshScrollView: UIScrollView! - var refreshControl: UIRefreshControl! +class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate { + /// The single, long-lived MainViewController that owns the app's data + /// pipeline (scheduleAllTasks). Held strongly so it stays alive — and the + /// engine keeps running — regardless of which tabs are visible or whether + /// Home has been opened. Created once via bootstrap() on first foreground. + private(set) static var shared: MainViewController? + + /// Creates and force-loads the shared instance if it does not yet exist. + /// loadViewIfNeeded() triggers viewDidLoad, which starts scheduleAllTasks. + /// Idempotent and main-thread only. Called from MainTabView on appear so + /// the engine runs even when Home lives in the Menu rather than a tab. + static func bootstrap() { + guard shared == nil else { return } + let vc = MainViewController() + shared = vc + vc.loadViewIfNeeded() + } + + var BGChart: LineChartView! + var BGChartFull: LineChartView! + var statsDisplayModel = StatsDisplayModel() + + /// The hosting controller's view — hidden during loading / first-time setup. + private var mainContentView: UIView! // Setup buttons for first-time configuration private var setupNightscoutButton: UIButton! @@ -137,7 +133,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let contactImageUpdater = ContactImageUpdater() private var cancellables = Set() - private var isViewHierarchyReady = false // Loading state management private var loadingOverlay: UIView? @@ -149,8 +144,58 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele ] private var loadingTimeoutTimer: Timer? + // MARK: - Programmatic UI Setup + + private func setupUI() { + view.backgroundColor = .systemBackground + + BGChart = LineChartView() + BGChart.backgroundColor = .systemBackground + + BGChartFull = LineChartView() + BGChartFull.backgroundColor = .systemBackground + + infoManager = InfoManager() + + let mainView = MainHomeView( + bgChart: BGChart, + bgChartFull: BGChartFull, + infoManager: infoManager, + statsModel: statsDisplayModel, + onRefresh: { [weak self] in self?.refresh() }, + onStatsTap: { [weak self] in self?.statsViewTapped() } + ) + let hosting = UIHostingController(rootView: mainView) + // Exclude the keyboard from the hosting controller's safe area. Home has + // no text input, but a stale keyboard frame replayed on foregrounding can + // otherwise compress the layout until a rotation recomputes the safe area. + hosting.safeAreaRegions = .container + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + let safeArea = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: safeArea.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + ]) + hosting.didMove(toParent: self) + mainContentView = hosting.view + } + override func viewDidLoad() { super.viewDidLoad() + // Adopt the singleton only if it is not already set (normally bootstrap() + // has set it to this same instance). Guarding prevents a stray instance + // from displacing the long-lived engine and spawning a second pipeline. + if MainViewController.shared == nil { + MainViewController.shared = self + } + + setupUI() loadDebugData() @@ -160,29 +205,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() - infoTable.rowHeight = 21 - infoTable.dataSource = self - infoTable.tableFooterView = UIView(frame: .zero) - infoTable.bounces = false - infoTable.addBorder(toSide: .Left, withColor: UIColor.darkGray.cgColor, andThickness: 2) - - infoManager = InfoManager(tableView: infoTable) - - smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) - view.layoutIfNeeded() - let shareUserName = Storage.shared.shareUserName.value let sharePassword = Storage.shared.sharePassword.value let shareServer = Storage.shared.shareServer.value == "US" ?KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer) - // setup show/hide small graph and stats + // setup show/hide graphs (first-time setup check) updateGraphVisibility() - statsView.isHidden = !Storage.shared.showStats.value - - // Tap on stats view to open full statistics screen - let statsTap = UITapGestureRecognizer(target: self, action: #selector(statsViewTapped)) - statsView.addGestureRecognizer(statsTap) BGChart.delegate = self BGChartFull.delegate = self @@ -198,6 +227,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // when runMigrationsIfNeeded() is called. This catches migrations deferred by a // background BGAppRefreshTask launch in Before-First-Unlock state. notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + // Posted by AppDelegate after Storage.reloadAll has refreshed every StorageValue + // following a BFU launch. If we're alive when this fires, our scheduled tasks + // were set up with BFU defaults (url='') and need to be redone. + notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil) #if !targetEnvironment(macCatalyst) notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) @@ -214,59 +247,17 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele scheduleAllTasks() - // Set up refreshScrollView for BGText - refreshScrollView = UIScrollView() - refreshScrollView.translatesAutoresizingMaskIntoConstraints = false - refreshScrollView.alwaysBounceVertical = true - view.addSubview(refreshScrollView) - - NSLayoutConstraint.activate([ - refreshScrollView.leadingAnchor.constraint(equalTo: BGText.leadingAnchor), - refreshScrollView.trailingAnchor.constraint(equalTo: BGText.trailingAnchor), - refreshScrollView.topAnchor.constraint(equalTo: BGText.topAnchor), - refreshScrollView.bottomAnchor.constraint(equalTo: BGText.bottomAnchor), - ]) - - refreshControl = UIRefreshControl() - refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) - refreshScrollView.addSubview(refreshControl) - refreshScrollView.alwaysBounceVertical = true - - refreshScrollView.delegate = self NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) - Observable.shared.bgText.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] newValue in - self?.BGText.text = newValue - } - .store(in: &cancellables) - - Observable.shared.directionText.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] newValue in - self?.DirectionText.text = newValue - } - .store(in: &cancellables) - - Observable.shared.deltaText.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] newValue in - self?.DeltaText.text = newValue - } - .store(in: &cancellables) - /// When an alarm is triggered, go to the snoozer tab Observable.shared.currentAlarm.$value .receive(on: DispatchQueue.main) .compactMap { $0 } - .sink { [weak self] _ in - guard let self = self, - let tabBarController = self.tabBarController, - let vcs = tabBarController.viewControllers, !vcs.isEmpty, - let snoozerIndex = self.getSnoozerTabIndex(), - snoozerIndex < vcs.count else { return } - tabBarController.selectedIndex = snoozerIndex + .sink { _ in + let orderedItems = Storage.shared.orderedTabBarItems() + if let index = orderedItems.firstIndex(of: .snoozer) { + Observable.shared.selectedTabIndex.value = index + } } .store(in: &cancellables) @@ -285,13 +276,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.showStats.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.statsView.isHidden = !Storage.shared.showStats.value - } - .store(in: &cancellables) - Publishers.MergeMany( Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(), @@ -312,13 +296,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.showSmallGraph.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateGraphVisibility() - } - .store(in: &cancellables) - Storage.shared.screenlockSwitchState.$value .receive(on: DispatchQueue.main) .sink { newValue in @@ -333,20 +310,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.graphTimeZoneEnabled.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.infoTable.reloadData() - } - .store(in: &cancellables) - - Storage.shared.graphTimeZoneIdentifier.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.infoTable.reloadData() - } - .store(in: &cancellables) - Storage.shared.speakBG.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -354,26 +317,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - // Observe all tab position changes with debouncing to handle batch updates - Publishers.MergeMany( - Storage.shared.homePosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.alarmsPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.remotePosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.nightscoutPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.snoozerPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.statisticsPosition.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.treatmentsPosition.$value.map { _ in () }.eraseToAnyPublisher() - ) - .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - self?.setupTabBar() - } - .store(in: &cancellables) - Storage.shared.url.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.updateNightscoutTabState() self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) @@ -458,13 +404,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateQuickActions() - // Delay initial tab setup to ensure view hierarchy is ready - // This prevents crashes when trying to modify tabs during viewWillAppear - DispatchQueue.main.async { [weak self] in - self?.isViewHierarchyReady = true - self?.setupTabBar() - } - speechSynthesizer.delegate = self // Check configuration and show appropriate UI @@ -577,199 +516,22 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } - private func setupTabBar() { - guard isViewHierarchyReady else { return } - - guard !isPresentedAsModal else { return } - - var tbc = tabBarController - if tbc == nil { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController as? UITabBarController - { - tbc = rootVC - } - } - - guard let tabBarController = tbc else { return } - - // If settings modal is presented, skip rebuild - it will happen when settings is dismissed - if tabBarController.presentedViewController != nil { - return - } - - rebuildTabs(tabBarController: tabBarController) - } - - /// Static method to rebuild tabs from anywhere in the app - /// This is useful when the MainViewController instance may not be in the tab bar - static func rebuildTabsIfNeeded() { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let tabBarController = window.rootViewController as? UITabBarController - else { return } - - let previousSelectedIndex = tabBarController.selectedIndex - - let storyboard = UIStoryboard(name: "Main", bundle: nil) - var viewControllers: [UIViewController] = [] - - let orderedItems = Storage.shared.orderedTabBarItems() - - for (index, item) in orderedItems.prefix(4).enumerated() { - let position = TabPosition.customizablePositions[index] - if let vc = createViewControllerStatic(for: item, position: position, storyboard: storyboard) { - viewControllers.append(vc) - } - } - - // Preserve existing Menu nav controller to keep its push stack intact - let existingMenuNav = (tabBarController.viewControllers ?? []).first(where: { - $0.tabBarItem.title == "Menu" - }) - if let menuNav = existingMenuNav { - menuNav.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4) - viewControllers.append(menuNav) - } else { - viewControllers.append(Self.makeMenuViewController(tag: 4)) - } - - if let presented = tabBarController.presentedViewController { - presented.dismiss(animated: false) { - tabBarController.setViewControllers(viewControllers, animated: false) - guard !viewControllers.isEmpty else { return } - let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) - tabBarController.selectedIndex = targetIndex - } - } else { - tabBarController.setViewControllers(viewControllers, animated: false) - guard !viewControllers.isEmpty else { return } - let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) - tabBarController.selectedIndex = targetIndex - } - } - - /// Static helper to create view controllers - private static func createViewControllerStatic(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { - let tag = position.tabIndex ?? 0 - - switch item { - case .home: - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { - return nil - } - mainVC.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag) - return mainVC - - case .alarms: - let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .remote: - let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .nightscout: - let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .snoozer: - let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .treatments: - let treatmentsVC = UIHostingController(rootView: TreatmentsView()) - treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return treatmentsVC - - case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: nil)) - let navController = UINavigationController(rootViewController: statsVC) - navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return navController - } - } - - private func rebuildTabs(tabBarController: UITabBarController) { - let previousSelectedIndex = tabBarController.selectedIndex - - let storyboard = UIStoryboard(name: "Main", bundle: nil) - var viewControllers: [UIViewController] = [] - - let orderedItems = Storage.shared.orderedTabBarItems() - - for (index, item) in orderedItems.prefix(4).enumerated() { - let position = TabPosition.customizablePositions[index] - if let vc = createViewController(for: item, position: position, storyboard: storyboard) { - viewControllers.append(vc) - } - } - - // Preserve existing Menu nav controller to keep its push stack intact - let existingMenuNav = (tabBarController.viewControllers ?? []).first(where: { - $0.tabBarItem.title == "Menu" - }) - if let menuNav = existingMenuNav { - menuNav.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4) - viewControllers.append(menuNav) - } else { - viewControllers.append(Self.makeMenuViewController(tag: 4)) - } - - tabBarController.setViewControllers(viewControllers, animated: false) - - guard !viewControllers.isEmpty else { return } - let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) - tabBarController.selectedIndex = targetIndex - - updateNightscoutTabState() - } - @objc private func navigateOnLAForeground() { - guard let tabBarController = tabBarController, - let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return } - - let targetIndex: Int + let orderedItems = Storage.shared.orderedTabBarItems() if Observable.shared.currentAlarm.value != nil, - let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count + let snoozerIndex = orderedItems.firstIndex(of: .snoozer) { - targetIndex = snoozerIndex - } else { - targetIndex = 0 - } - - if let presented = tabBarController.presentedViewController { - presented.dismiss(animated: false) { - tabBarController.selectedIndex = targetIndex - } + Observable.shared.selectedTabIndex.value = snoozerIndex } else { - tabBarController.selectedIndex = targetIndex - } - } - - private func getSnoozerTabIndex() -> Int? { - guard let tabBarController = tabBarController, - let viewControllers = tabBarController.viewControllers else { return nil } - - for (index, vc) in viewControllers.enumerated() { - if let _ = vc as? SnoozerViewController { - return index - } + Observable.shared.selectedTabIndex.value = 0 } - - return nil } @objc private func statsViewTapped() { #if !targetEnvironment(macCatalyst) - let position = Storage.shared.position(for: .stats).normalized - if position != .menu, let tabIndex = position.tabIndex, let tbc = tabBarController { - tbc.selectedIndex = tabIndex + let orderedItems = Storage.shared.orderedTabBarItems() + if let statsIndex = orderedItems.firstIndex(of: .stats) { + Observable.shared.selectedTabIndex.value = statsIndex return } #endif @@ -781,94 +543,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele present(hostingController, animated: true) } - private func createViewController(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { - let tag = position.tabIndex ?? 0 - - switch item { - case .home: - tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag) - return self - - case .alarms: - let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .remote: - let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .nightscout: - let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .snoozer: - let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") - vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return vc - - case .treatments: - let treatmentsVC = UIHostingController(rootView: TreatmentsView()) - treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return treatmentsVC - - case .stats: - let statsVC = UIHostingController(rootView: AggregatedStatsContentView(mainViewController: self)) - let navController = UINavigationController(rootViewController: statsVC) - navController.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) - return navController - } - } - - private static func makeMenuViewController(tag: Int) -> UIViewController { - let menuVC = MoreMenuViewController() - let navController = UINavigationController(rootViewController: menuVC) - navController.navigationBar.prefersLargeTitles = true - navController.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: tag) - navController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - return navController - } - - private func createComingSoonViewController(title: String, icon: String) -> UIViewController { - let vc = UIViewController() - vc.view.backgroundColor = .systemBackground - - let stackView = UIStackView() - stackView.axis = .vertical - stackView.alignment = .center - stackView.spacing = 16 - stackView.translatesAutoresizingMaskIntoConstraints = false - - let imageView = UIImageView(image: UIImage(systemName: icon)) - imageView.tintColor = .secondaryLabel - imageView.contentMode = .scaleAspectFit - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: 60), - imageView.heightAnchor.constraint(equalToConstant: 60), - ]) - - let titleLabel = UILabel() - titleLabel.text = title - titleLabel.font = .preferredFont(forTextStyle: .title1) - titleLabel.textColor = .label - - stackView.addArrangedSubview(imageView) - stackView.addArrangedSubview(titleLabel) - - vc.view.addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.centerXAnchor.constraint(equalTo: vc.view.centerXAnchor), - stackView.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor), - ]) - - vc.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - return vc - } - // 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" @@ -883,7 +557,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } deinit { - NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil) + NotificationCenter.default.removeObserver(self) } // Clean all timers and start new ones when refreshing @@ -913,98 +587,30 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } - MinAgoText.text = "Refreshing" Observable.shared.minAgoText.value = "Refreshing" scheduleAllTasks() currentCage = nil currentSage = nil currentIage = nil - refreshControl.endRefreshing() - } - - // Scroll down BGText when refreshing - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if scrollView == refreshScrollView { - let yOffset = scrollView.contentOffset.y - if yOffset < 0 { - BGText.transform = CGAffineTransform(translationX: 0, y: -yOffset) - } else { - BGText.transform = CGAffineTransform.identity - } - } } - override func viewWillAppear(_: Bool) { + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value - infoTable.reloadData() if Observable.shared.chartSettingsChanged.value { updateBGGraphSettings() - - smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) - view.layoutIfNeeded() - Observable.shared.chartSettingsChanged.value = false } } - private var timeZoneOverrideInfoValue: String? { - guard Storage.shared.graphTimeZoneEnabled.value, - let overrideTimeZone = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) - else { - return nil - } - - return overrideTimeZone.identifier - } - - // Info Table Functions - func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - guard let infoManager = infoManager else { - return 0 - } - let overrideRowCount = timeZoneOverrideInfoValue == nil ? 0 : 1 - return infoManager.numberOfRows() + overrideRowCount - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell", for: indexPath) - - if indexPath.row == 0, let timeZoneOverrideInfoValue { - cell.textLabel?.text = "Time Zone" - cell.detailTextLabel?.text = timeZoneOverrideInfoValue - return cell - } - - let adjustedIndexPath: IndexPath - if timeZoneOverrideInfoValue != nil { - adjustedIndexPath = IndexPath(row: indexPath.row - 1, section: indexPath.section) - } else { - adjustedIndexPath = indexPath - } - - if let values = infoManager.dataForIndexPath(adjustedIndexPath) { - cell.textLabel?.text = values.name - cell.detailTextLabel?.text = values.value - } else { - cell.textLabel?.text = "" - cell.detailTextLabel?.text = "" - } - - return cell - } - @objc func appMovedToBackground() { // Allow screen to turn off UIApplication.shared.isIdleTimerDisabled = false // We want to always come back to the home screen - if let tabBarController = tabBarController, - let vcs = tabBarController.viewControllers, !vcs.isEmpty - { - tabBarController.selectedIndex = 0 - } + Observable.shared.selectedTabIndex.value = 0 if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() @@ -1091,28 +697,21 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele runMigrationsIfNeeded() } + @objc func handleBFUReloadCompleted() { + // Show the loading overlay so the user sees feedback during the 2-5s + // while tasks re-run with the now-correct credentials. Tasks scheduled + // before reload used url='' and rescheduled themselves 60s out — reset + // them so they run within their normal 2-5s initial delay. + loadingStates = ["bg": false, "profile": false, "deviceStatus": false] + isInitialLoad = true + setupLoadingState() + showLoadingOverlay() + scheduleAllTasks() + } + @objc func appCameToForeground() { - // If the app was cold-launched in Before-First-Unlock state (e.g. by BGAppRefreshTask - // after a reboot), all StorageValues were cached from encrypted UserDefaults and hold - // their defaults. Reload everything from disk now that the device is unlocked, firing - // Combine observers only for values that actually changed. - LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(LogRedactor.url(Storage.shared.url.value))'") - if Storage.shared.needsBFUReload { - Storage.shared.needsBFUReload = false - LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues") - Storage.shared.reloadAll() - LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(LogRedactor.url(Storage.shared.url.value))'") - // Show the loading overlay so the user sees feedback during the 2-5s - // while tasks re-run with the now-correct credentials. - loadingStates = ["bg": false, "profile": false, "deviceStatus": false] - isInitialLoad = true - setupLoadingState() - showLoadingOverlay() - // Tasks were scheduled during BFU viewDidLoad with url="" — they fired, found no - // data source, and rescheduled themselves 60s out. Reset them now so they run - // within their normal 2-5s initial delay using the now-correct credentials. - scheduleAllTasks() - } + // BFU recovery (Storage.reloadAll) is driven by AppDelegate; this controller + // reacts via .bfuReloadCompleted in handleBFUReloadCompleted() above. // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value @@ -1184,8 +783,25 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } - @objc override func viewDidAppear(_: Bool) { + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) showHideNSDetails() + + // Re-render the graph every time Home appears. The single MainViewController + // is reused across tab/Menu hosts, so its chart view gets re-parented when + // the user switches to Home or moves it between the tab bar and the Menu — + // and Charts does not redraw itself after a re-parent. Rebuilding here keeps + // the curve visible regardless of how Home was reached. It also recovers the + // one-shot firstGraphLoad zoom that is skipped while the view is off-screen + // (force-loaded headless when Home lives in the Menu). Deferred one runloop + // so the nested SwiftUI chart has its final frame; updateBGGraph's own + // width>0 guard skips the initial zoom until it does. + if !bgData.isEmpty { + DispatchQueue.main.async { [weak self] in + self?.updateBGGraph() + } + } + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.startFromCurrentState() #endif @@ -1198,42 +814,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele return String(format: "%02d:%02d", hours, minutes) } - 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 - } - } - } - func showHideNSDetails() { - if isInitialLoad || !isDataSourceConfigured() { - return - } - - var isHidden = false - if !IsNightscoutEnabled() { - isHidden = true - } - - LoopStatusLabel.isHidden = isHidden - if IsNotLooping { - PredictionLabel.isHidden = true - } else { - PredictionLabel.isHidden = isHidden - } - infoTable.isHidden = isHidden - - if Storage.shared.hideInfoTable.value { - infoTable.isHidden = true - } - - updateNightscoutTabState() + // Info table visibility is handled reactively by MainHomeView. } func updateBadge(val: Int) { @@ -1248,30 +830,18 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele func updateBGTextAppearance() { if bgData.count > 0 { let latestBG = bgData[bgData.count - 1].sgv - var color = NSUIColor.label if Storage.shared.colorBGText.value { let thresholds = UnitSettingsStore.shared.effectiveThresholds() if Double(latestBG) >= thresholds.high { - color = NSUIColor.systemYellow Observable.shared.bgTextColor.value = .yellow } else if Double(latestBG) <= thresholds.low { - color = NSUIColor.systemRed Observable.shared.bgTextColor.value = .red } else { - color = NSUIColor.systemGreen Observable.shared.bgTextColor.value = .green } } else { Observable.shared.bgTextColor.value = .primary } - - BGText.textColor = color - - if latestBG <= globalVariables.minDisplayGlucose || latestBG >= globalVariables.maxDisplayGlucose { - BGText.font = UIFont.systemFont(ofSize: 65, weight: .black) - } else { - BGText.font = UIFont.systemFont(ofSize: 85, weight: .black) - } } } @@ -1293,25 +863,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Update this view controller overrideUserInterfaceStyle = style - // Update the tab bar controller (affects all tabs) - tabBarController?.overrideUserInterfaceStyle = style - // Update the window (affects the entire app including modals) window.overrideUserInterfaceStyle = style } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - // When system appearance changes and we're in "System" mode, notify all observers - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - // Post notification so other view controllers can update if needed - NotificationCenter.default.post(name: .appearanceDidChange, object: nil) - } - } - func bgDirectionGraphic(_ value: String) -> String { let // graphics:[String:String]=["Flat":"\u{2192}","DoubleUp":"\u{21C8}","SingleUp":"\u{2191}","FortyFiveUp":"\u{2197}\u{FE0E}","FortyFiveDown":"\u{2198}\u{FE0E}","SingleDown":"\u{2193}","DoubleDown":"\u{21CA}","None":"-","NOT COMPUTABLE":"-","RATE OUT OF RANGE":"-"] graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"] @@ -1648,15 +1203,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele present(navController, animated: true) } - private func hideGraphs() { - BGChart.isHidden = true - BGChartFull.isHidden = true - } - - private func showGraphs() { - updateGraphVisibility() - } - private func makeCloseBarButtonItem() -> UIBarButtonItem { let button = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissModal)) button.tintColor = .systemBlue @@ -1664,61 +1210,18 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } private func hideAllDataUI() { - // Hide graphs - BGChart.isHidden = true - BGChartFull.isHidden = true - - // Hide BG display elements - BGText.isHidden = true - DeltaText.isHidden = true - DirectionText.isHidden = true - MinAgoText.isHidden = true - serverText.isHidden = true - - // Hide info table and stats - infoTable.isHidden = true - statsView.isHidden = true - - // Hide loop status and prediction - LoopStatusLabel.isHidden = true - PredictionLabel.isHidden = true + mainContentView?.isHidden = true } private func showAllDataUI() { - // Show BG display elements - BGText.isHidden = false - DeltaText.isHidden = false - DirectionText.isHidden = false - MinAgoText.isHidden = false - serverText.isHidden = false - - // Show graphs based on settings - updateGraphVisibility() - - // Show/hide info table and stats based on user settings - let isNightscoutEnabled = IsNightscoutEnabled() - if isNightscoutEnabled { - infoTable.isHidden = Storage.shared.hideInfoTable.value - LoopStatusLabel.isHidden = false - PredictionLabel.isHidden = IsNotLooping - } else { - infoTable.isHidden = true - LoopStatusLabel.isHidden = true - PredictionLabel.isHidden = true - } - - statsView.isHidden = !Storage.shared.showStats.value + mainContentView?.isHidden = false } private func updateGraphVisibility() { - let isFirstTimeSetup = !isDataSourceConfigured() - - if isFirstTimeSetup { - BGChart.isHidden = true - BGChartFull.isHidden = true - } else { - BGChart.isHidden = false - BGChartFull.isHidden = !Storage.shared.showSmallGraph.value + // Graph and component visibility is handled reactively by MainHomeView. + // This method now only manages the overall content visibility for first-time setup. + if !isDataSourceConfigured() { + mainContentView?.isHidden = true } } diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift new file mode 100644 index 000000000..8bb0a1373 --- /dev/null +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -0,0 +1,220 @@ +// 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() + + var body: some View { + List { + // Settings + Section { + NavigationLink(value: SettingsRoute.settings) { + Label("Settings", systemImage: "gearshape") + } + } + + // Features + Section("Features") { + ForEach(TabItem.featureOrder) { item in + FullRowButton(showsChevron: true) { + let tabs = Storage.shared.orderedTabBarItems() + if let tabIndex = tabs.firstIndex(of: item) { + Observable.shared.selectedTabIndex.value = tabIndex + } else { + pendingRoute = MenuRoute(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") { + Link(destination: URL(string: "https://loopfollowdocs.org/")!) { + HStack { + Label("LoopFollow Docs", systemImage: "book") + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.tertiary) + } + } + + Link(destination: URL(string: "https://discord.gg/KQgk3gzuYU")!) { + HStack { + Label("Loop and Learn Discord", systemImage: "bubble.left.and.bubble.right") + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.tertiary) + } + } + + Link(destination: URL(string: "https://www.facebook.com/groups/loopfollowlnl")!) { + HStack { + Label("LoopFollow Facebook Group", systemImage: "person.2.fill") + 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) + } + } + .listStyle(.insetGrouped) + .navigationTitle("Menu") + .navigationBarTitleDisplayMode(.large) + .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: - 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 = "No Logs Available" + alertMessage = "There are no logs to share." + showAlert = true + return + } + let avc = UIActivityViewController(activityItems: files, applicationActivities: nil) + UIApplication.shared.topMost?.present(avc, animated: true) + } + + 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: – Full-row tappable button + +private struct FullRowButton: 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() + } + } +} diff --git a/LoopFollow/ViewControllers/MoreMenuViewController.swift b/LoopFollow/ViewControllers/MoreMenuViewController.swift deleted file mode 100644 index 2693cde33..000000000 --- a/LoopFollow/ViewControllers/MoreMenuViewController.swift +++ /dev/null @@ -1,466 +0,0 @@ -// LoopFollow -// MoreMenuViewController.swift - -import Combine -import SwiftUI -import UIKit - -class MoreMenuViewController: UIViewController { - private var tableView: UITableView! - private var cancellables = Set() - private var fallbackMainViewController: MainViewController? - var needsTabRebuild = false - - // 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 style: MenuItemStyle - let action: () -> Void - - init(title: String, icon: String, style: MenuItemStyle = .navigation, action: @escaping () -> Void = {}) { - self.title = title - self.icon = icon - self.style = style - self.action = action - } - } - - struct MenuSection { - let title: String? - let items: [MenuItem] - } - - private var menuSections: [MenuSection] = [] - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - navigationItem.title = "Menu" - navigationItem.largeTitleDisplayMode = .always - navigationItem.backButtonDisplayMode = .minimal - - // Apply appearance mode - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.overrideUserInterfaceStyle = mode.userInterfaceStyle - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - - setupTableView() - updateMenuItems() - - Task { [weak self] in - await self?.fetchVersionInfo() - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(false, animated: animated) - navigationController?.navigationBar.prefersLargeTitles = true - updateMenuItems() - tableView.reloadData() - Observable.shared.settingsPath.set(NavigationPath()) - - if needsTabRebuild { - needsTabRebuild = false - MainViewController.rebuildTabsIfNeeded() - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } - - // MARK: - Setup - - private func setupTableView() { - tableView = UITableView(frame: view.bounds, style: .insetGrouped) - tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.delegate = self - tableView.dataSource = self - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - tableView.contentInsetAdjustmentBehavior = .automatic - - view.addSubview(tableView) - - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } - - // MARK: - Menu construction - - private func updateMenuItems() { - let build = BuildDetails.default - let ver = AppVersionManager().version() - - var sections: [MenuSection] = [ - MenuSection(title: nil, items: [ - MenuItem(title: "Settings", icon: "gearshape") { [weak self] in - self?.openSettings() - }, - ]), - ] - - sections.append( - MenuSection(title: "Features", items: TabItem.featureOrder.map { item in - MenuItem(title: item.displayName, icon: item.icon) { [weak self] in - self?.openItem(item) - } - }) - ) - - sections.append(contentsOf: [ - 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/KQgk3gzuYU") - }, - 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) - )) - - return items - }()), - ]) - - menuSections = sections - } - - // 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 push onto navigation stack - pushItem(item) - } - - private func pushItem(_ item: TabItem) { - switch item { - case .home: - openHome() - case .alarms: - openAlarmsConfig() - case .remote: - openRemote() - case .nightscout: - openNightscout() - case .snoozer: - openSnoozer() - case .treatments: - openTreatments() - case .stats: - openAggregatedStats() - } - } - - private func openSettings() { - needsTabRebuild = true - let settingsView = SettingsMenuView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let settingsVC = NavBarHidingHostingController(rootView: settingsView) - settingsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(settingsVC, animated: true) - } - - private func openAlarmsConfig() { - let alarmsView = AlarmsContainerView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let alarmsVC = NavBarHidingHostingController(rootView: alarmsView) - alarmsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(alarmsVC, animated: true) - } - - private func openRemote() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let remoteVC = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - remoteVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(remoteVC, animated: true) - remoteVC.navigationItem.largeTitleDisplayMode = .never - } - - private func openNightscout() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let nightscoutVC = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") - nightscoutVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(nightscoutVC, animated: true) - nightscoutVC.navigationItem.largeTitleDisplayMode = .never - } - - private func openSnoozer() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let snoozerVC = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") - snoozerVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(snoozerVC, animated: true) - snoozerVC.navigationItem.largeTitleDisplayMode = .never - } - - private func openTreatments() { - let treatmentsView = TreatmentsView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let treatmentsVC = NavBarHidingHostingController(rootView: treatmentsView) - treatmentsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(treatmentsVC, animated: true) - } - - private func openAggregatedStats() { - guard let mainVC = getMainViewController() else { - presentSimpleAlert(title: "Error", message: "Unable to access data") - return - } - - let statsView = AggregatedStatsContentView(mainViewController: mainVC) - let statsVC = UIHostingController(rootView: statsView) - statsVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(statsVC, animated: true) - } - - private func openHome() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { return } - mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - mainVC.navigationItem.largeTitleDisplayMode = .never - navigationController?.pushViewController(mainVC, animated: true) - } - - private func openViewLog() { - let logView = LogView(onBack: { [weak self] in - self?.navigationController?.popViewController(animated: true) - }) - let logVC = NavBarHidingHostingController(rootView: logView) - logVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - navigationController?.pushViewController(logVC, 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 - - private func getMainViewController() -> MainViewController? { - guard let tabBarController = tabBarController else { return nil } - - for vc in tabBarController.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - - if let fallbackMainViewController { - return fallbackMainViewController - } - - let storyboard = UIStoryboard(name: "Main", bundle: nil) - guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { - return nil - } - - mainVC.isPresentedAsModal = true - fallbackMainViewController = mainVC - return mainVC - } -} - -// MARK: - NavBarHidingHostingController - -/// A UIHostingController subclass that hides the UIKit navigation bar. -/// Used for SwiftUI views that have their own NavigationStack/NavigationView -/// to prevent double navigation bars when pushed onto a UINavigationController. -private class NavBarHidingHostingController: UIHostingController { - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(true, animated: animated) - } -} - -// 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].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].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 - } - - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - let item = menuSections[indexPath.section].items[indexPath.row] - if case .detail = item.style { return } - item.action() - } -} diff --git a/LoopFollow/ViewControllers/NightScoutViewController.swift b/LoopFollow/ViewControllers/NightScoutViewController.swift index 96a4b7f47..8dd1fc4b2 100644 --- a/LoopFollow/ViewControllers/NightScoutViewController.swift +++ b/LoopFollow/ViewControllers/NightScoutViewController.swift @@ -6,13 +6,29 @@ import UIKit import WebKit class NightscoutViewController: UIViewController { - @IBOutlet var webView: WKWebView! + var webView: WKWebView! private var cancellables = Set() override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .systemBackground overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + // Create WKWebView programmatically + let webConfiguration = WKWebViewConfiguration() + webConfiguration.mediaTypesRequiringUserActionForPlayback = [] + webView = WKWebView(frame: .zero, configuration: webConfiguration) + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + + let safeArea = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: safeArea.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + ]) + // Listen for appearance setting changes Storage.shared.appearanceMode.$value .receive(on: DispatchQueue.main) @@ -21,14 +37,6 @@ class NightscoutViewController: UIViewController { } .store(in: &cancellables) - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - .store(in: &cancellables) - var url = Storage.shared.url.value let token = Storage.shared.token.value @@ -57,16 +65,6 @@ class NightscoutViewController: UIViewController { sender.endRefreshing() } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - } - } - func clearWebCache() { let dataStore = WKWebsiteDataStore.default() let cacheTypes = Set([WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) diff --git a/LoopFollow/ViewControllers/NightscoutContentView.swift b/LoopFollow/ViewControllers/NightscoutContentView.swift new file mode 100644 index 000000000..fd724bd19 --- /dev/null +++ b/LoopFollow/ViewControllers/NightscoutContentView.swift @@ -0,0 +1,38 @@ +// LoopFollow +// NightscoutContentView.swift + +import SwiftUI + +struct NightscoutContentView: View { + @ObservedObject private var url = Storage.shared.url + @ObservedObject private var token = Storage.shared.token + + var body: some View { + if url.value.isEmpty { + VStack(spacing: 16) { + Image(systemName: "network") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + Text("Please enter your Nightscout URL in Settings.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } else { + NightscoutWebView() + // NightscoutViewController loads the page once in viewDidLoad, + // so recreate it when the URL or token changes. + .id(url.value + "|" + token.value) + } + } +} + +private struct NightscoutWebView: UIViewControllerRepresentable { + func makeUIViewController(context _: Context) -> NightscoutViewController { + NightscoutViewController() + } + + func updateUIViewController(_ uiViewController: NightscoutViewController, context _: Context) { + uiViewController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + } +} diff --git a/LoopFollow/ViewControllers/SettingsViewController.swift b/LoopFollow/ViewControllers/SettingsViewController.swift deleted file mode 100644 index b27e32171..000000000 --- a/LoopFollow/ViewControllers/SettingsViewController.swift +++ /dev/null @@ -1,73 +0,0 @@ -// LoopFollow -// SettingsViewController.swift - -import Combine -import SwiftUI -import UIKit - -final class SettingsViewController: UIViewController { - // MARK: Stored properties - - private var host: UIHostingController! - private var cancellables = Set() - - // MARK: Life-cycle - - override func viewDidLoad() { - super.viewDidLoad() - - // Build SwiftUI menu - host = UIHostingController(rootView: SettingsMenuView()) - - // Appearance mode override - host.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - - // Listen for appearance setting changes - Storage.shared.appearanceMode.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.updateAppearance(mode) - } - .store(in: &cancellables) - - // Listen for system appearance changes (when in System mode) - NotificationCenter.default.publisher(for: .appearanceDidChange) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateAppearance(Storage.shared.appearanceMode.value) - } - .store(in: &cancellables) - - // Embed - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(host.view) - NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - host.view.topAnchor.constraint(equalTo: view.topAnchor), - host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - host.didMove(toParent: self) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - Observable.shared.settingsPath.set(NavigationPath()) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if Storage.shared.appearanceMode.value == .system, - previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle - { - updateAppearance(.system) - } - } - - private func updateAppearance(_ mode: AppearanceMode) { - host.overrideUserInterfaceStyle = mode.userInterfaceStyle - } -} From 3f9b6cde2af1689b870991156faf9ac3f46ba15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 20:09:55 +0200 Subject: [PATCH 02/22] Suppress silent passive notifications in willPresent handler (#655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UNUserNotificationCenterDelegate.willPresent returned [.banner, .sound, .badge] unconditionally, which meant any notification iOS routed through this handler while the app was foregrounded produced sound — including the Live Activity push-to-start payload, which is intentionally silent (interruption-level: passive, empty title/body). Now returns [] for passive notifications and for ones with empty title/body. The four intentional alerts (renewal-failed, APNs credentials missing, push-to-start token missing, alarms) all use non-empty title/body and the default .active interruption level, so they continue to surface. Also expanded the willPresent log line with interruptionLevel and title/body presence so future reports can confirm whether iOS routed a given payload here. --- LoopFollow/Application/AppDelegate.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index f3a643477..6c9d8e884 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -263,12 +263,21 @@ extension AppDelegate: UNUserNotificationCenterDelegate { willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - // Log the notification - let userInfo = notification.request.content.userInfo - let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() - LogManager.shared.log(category: .general, message: "Will present notification: keys=\(userInfoKeys)") + let content = notification.request.content + let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() + LogManager.shared.log( + category: .general, + message: "Will present notification: keys=\(userInfoKeys), interruption=\(content.interruptionLevel.rawValue), title=\(content.title.isEmpty ? "empty" : "set"), body=\(content.body.isEmpty ? "empty" : "set")" + ) + + // Suppress notifications iOS routes here that we never intended to surface: + // the Live Activity push-to-start uses interruption-level: passive with empty + // title/body and must not produce a banner or sound when LF is foregrounded. + if content.interruptionLevel == .passive || (content.title.isEmpty && content.body.isEmpty) { + completionHandler([]) + return + } - // Show the notification even when app is in foreground completionHandler([.banner, .sound, .badge]) } } From 89d4fa4fa43eede367c00dc2e1d530dfd864ea80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 20:39:23 +0200 Subject: [PATCH 03/22] Bump fastlane to 2.235.0 and jwt to 3.2.0 (CVE-2026-45363) (#663) * Bump fastlane to 2.235.0 and jwt to 3.2.0 (CVE-2026-45363) jwt < 3.2.0 accepts attacker-forged tokens when an empty or nil key is used with HMAC algorithms (GHSA-c32j-vqhx-rx3x). fastlane 2.233.1 pinned jwt < 3, blocking the fix. fastlane 2.235.0 relaxes that to jwt < 4, allowing the upgrade to 3.2.0. * Remove json and addressable pins no longer needed with fastlane 2.235.0 --- Gemfile | 4 +- Gemfile.lock | 103 ++++++++++++++++++++++++++------------------------- 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/Gemfile b/Gemfile index 1a3b246f6..0eb90cb08 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,2 @@ source "https://rubygems.org" -gem "fastlane", "2.233.1" -gem "json", ">=2.19.2" -gem "addressable", ">=2.9.0" +gem "fastlane", "2.235.0" diff --git a/Gemfile.lock b/Gemfile.lock index 25ea69c8c..d255873eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1206.0) - aws-sdk-core (3.241.4) + aws-partitions (1.1254.0) + aws-sdk-core (3.250.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,19 +17,19 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.121.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-kms (1.128.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + base64 (0.3.0) benchmark (0.5.0) - bigdecimal (4.0.1) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -71,17 +71,17 @@ GEM faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.233.1) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) + fastimage (2.4.1) + fastlane (2.235.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2.0) + base64 (~> 0.2) benchmark (>= 0.1.0) - bundler (>= 1.17.3, < 5.0.0) + bundler (>= 2.4.0, < 5.0.0) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) @@ -96,18 +96,18 @@ GEM gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-env (>= 1.6.0, < 2.3.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.1.0, < 4) logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) + mutex_m (~> 0.3) naturally (~> 2.2) - nkf (~> 0.2.0) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) @@ -124,39 +124,44 @@ GEM xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.101.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.62.0) + google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.5.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.60.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) + google-logging-utils (0.2.0) + googleauth (1.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) @@ -166,13 +171,13 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.3) - jwt (2.10.2) + json (2.19.7) + jwt (3.2.0) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.19.1) + multi_json (1.21.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) @@ -182,13 +187,13 @@ GEM os (1.1.4) ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.2) - rake (13.3.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) @@ -230,9 +235,7 @@ PLATFORMS ruby DEPENDENCIES - addressable (>= 2.9.0) - fastlane (= 2.233.1) - json (>= 2.19.2) + fastlane (= 2.235.0) BUNDLED WITH - 4.0.6 + 4.0.12 From 2f8608d3ac3d3ecd7c3b28846c4b7bf6c0a44fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 20:46:05 +0200 Subject: [PATCH 04/22] Fix BG history truncation caused by duplicate readings from multiple uploaders (#662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Increase Nightscout BG entry count to handle multiple uploaders Some users have multiple sources uploading BG entries to Nightscout for the same sensor (e.g. a closed-loop system plus two Dexcom platform apps), resulting in up to 3 entries per 5-minute slot. The previous count cap of days × 2 × 288 was too low in those cases, silently truncating the oldest portion of the requested time window before deduplication. Raise the multiplier from 2 to 4 in both BGData.swift and StatsDataFetcher.swift, giving headroom for up to 4 uploaders. The date[$gte] filter still bounds the window, so no extra data is returned when fewer entries exist. * Supplement Dex Share with NS data when Dex doesn't cover the full window When Dexcom credentials are present, LoopFollow fetches from Dex Share first. If the Dex data doesn't reach back to the start of the requested window (e.g. Dex Share's 24h cap when more days are configured, or any other gap), fall through to webLoadNSBGData so Nightscout fills in the older portion. The existing merge logic already handles stitching the two sources together correctly. * Deduplicate Dex Share readings before use Multiple Dexcom uploaders (e.g. G7 iPhone app + Apple Watch) each write readings to Dexcom's cloud, causing the Share API to return 2+ entries per 5-minute slot. With the API's hard cap of 288 readings, duplicates consume the budget and the returned data covers only ~15h instead of 24h. Dedup Dex Share data with the same 30-second window used for Nightscout, so both the NS-supplement path and the direct ProcessDexBGData path receive clean, deduplicated readings. * Extract BG entry-count multiplier into named globalVariables.maxExpectedUploaders * Reuse deduplicateBGReadings helper for Dexcom Share dedup --- LoopFollow/Controllers/Nightscout/BGData.swift | 16 +++++++++++----- LoopFollow/Helpers/Globals.swift | 6 ++++++ LoopFollow/Stats/StatsDataFetcher.swift | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 4aa2a02ae..b39e46520 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -33,11 +33,17 @@ extension MainViewController { return } - // Dexcom only returns 24 hrs of data. If we need more, call NS. - if graphHours > 24, IsNightscoutEnabled() { - self.webLoadNSBGData(dexData: data) + // Dexcom Share can return duplicate readings when multiple uploaders + // write to the same Dexcom account. Dedup before any further use. + let dedupedData = self.deduplicateBGReadings(data) + + // Supplement with NS if Dex data doesn't cover the full requested window. + let dexCutoff = dateTimeUtils.getNowTimeIntervalUTC() - Double(graphHours) * 3600 + let dexCoversFull = dedupedData.last.map { $0.date <= dexCutoff } ?? false + if !dexCoversFull, IsNightscoutEnabled() { + self.webLoadNSBGData(dexData: dedupedData) } else { - self.ProcessDexBGData(data: self.deduplicateBGReadings(data), sourceName: "Dexcom") + self.ProcessDexBGData(data: dedupedData, sourceName: "Dexcom") } } } @@ -52,7 +58,7 @@ extension MainViewController { var parameters: [String: String] = [:] let date = Calendar.current.date(byAdding: .day, value: -1 * Storage.shared.downloadDays.value, to: Date())! - parameters["count"] = "\(Storage.shared.downloadDays.value * 2 * 24 * 60 / 5)" + parameters["count"] = "\(Storage.shared.downloadDays.value * globalVariables.maxExpectedUploaders * 24 * 60 / 5)" parameters["find[date][$gte]"] = "\(Int(date.timeIntervalSince1970 * 1000))" // Exclude 'cal' entries diff --git a/LoopFollow/Helpers/Globals.swift b/LoopFollow/Helpers/Globals.swift index 37fcfab83..4fe8005fe 100644 --- a/LoopFollow/Helpers/Globals.swift +++ b/LoopFollow/Helpers/Globals.swift @@ -18,4 +18,10 @@ enum globalVariables { // BG readings and prediction values on the graph. static let minDisplayGlucose: Int = 39 static let maxDisplayGlucose: Int = 400 + + // Number of apps that may upload BG to the same account (a looping system, + // the Dexcom app, Apple Watch, ...). Each one writes a duplicate reading per + // slot, so the Nightscout entry-count request is multiplied by this to avoid + // truncating history before the date filter bounds the window. + static let maxExpectedUploaders = 4 } diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index baf5f1571..18353dce0 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -26,7 +26,7 @@ class StatsDataFetcher { var parameters: [String: String] = [:] let utcISODateFormatter = ISO8601DateFormatter() let startDate = dataService?.startDate ?? dateTimeUtils.displayCalendar().date(byAdding: .day, value: -1 * days, to: Date())! - parameters["count"] = "\(days * 2 * 24 * 60 / 5)" + parameters["count"] = "\(days * globalVariables.maxExpectedUploaders * 24 * 60 / 5)" parameters["find[dateString][$gte]"] = utcISODateFormatter.string(from: startDate) parameters["find[type][$ne]"] = "cal" From a9d7330cb53065e891adc993cbe9d17aa4c0a3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 21:10:00 +0200 Subject: [PATCH 05/22] Match Trio's override/temp-target colors for non-Loop backends (#668) --- LoopFollow/Controllers/Graphs.swift | 25 ++++++++++++++++--- .../Nightscout/Treatments/Overrides.swift | 2 +- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index e8a60be7d..da0259ff1 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -7,6 +7,22 @@ import UIKit import Charts +/// Fill colors for the override and temp-target bars on the BG graph. +/// +/// Loop draws overrides green and temp targets purple, while Trio (and other +/// OpenAPS-based algorithms) use the inverse — overrides purple, temp targets +/// green. We follow the active backend's convention so the colors match the +/// looping app the user is running. +enum TreatmentGraphColors { + static var override: NSUIColor { + Storage.shared.device.value == "Loop" ? .systemGreen : .systemPurple + } + + static var tempTarget: NSUIColor { + Storage.shared.device.value == "Loop" ? .systemPurple : .systemGreen + } +} + enum GraphDataIndex: Int { case bg = 0 case prediction = 1 @@ -201,7 +217,7 @@ class TempTargetRenderer: LineChartRenderer { } context.saveGState() - context.setFillColor(NSUIColor.systemPurple.withAlphaComponent(0.5).cgColor) + context.setFillColor(TreatmentGraphColors.tempTarget.withAlphaComponent(0.5).cgColor) context.fill(rect) context.restoreGState() } @@ -381,7 +397,7 @@ extension MainViewController { lineOverride.lineWidth = 0 lineOverride.drawFilledEnabled = true lineOverride.fillFormatter = OverrideFillFormatter() - lineOverride.fillColor = NSUIColor.systemGreen + lineOverride.fillColor = TreatmentGraphColors.override lineOverride.fillAlpha = 0.6 lineOverride.drawCirclesEnabled = false lineOverride.axisDependency = YAxis.AxisDependency.right @@ -1460,7 +1476,7 @@ extension MainViewController { lineOverride.lineWidth = 0 lineOverride.drawFilledEnabled = true lineOverride.fillFormatter = OverrideFillFormatter() - lineOverride.fillColor = NSUIColor.systemGreen + lineOverride.fillColor = TreatmentGraphColors.override lineOverride.fillAlpha = 0.6 lineOverride.drawCirclesEnabled = false lineOverride.axisDependency = YAxis.AxisDependency.right @@ -1683,6 +1699,9 @@ extension MainViewController { var smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet chart.clear() smallChart.clear() + // Refresh the fill color in case the backend (Loop vs Trio) changed. + chart.fillColor = TreatmentGraphColors.override + smallChart.fillColor = TreatmentGraphColors.override let thisData = overrideGraphData var colors = [NSUIColor]() diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index 322d5c1df..3b61dad8e 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -80,7 +80,7 @@ extension MainViewController { } Observable.shared.override.value = activeOverrideNote - if Storage.shared.device.value == "Trio" { + if Storage.shared.device.value != "Loop" { if let note = activeOverrideNote { infoManager.updateInfoData(type: .override, value: note) } else { From 566c61a55da84011df2d5b997f346d9bf9c22bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 21:49:02 +0200 Subject: [PATCH 06/22] Quick-Pick Boluses and Meals for remote commands (#603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Common remote commands * Help texts * Rename Common to Quick-Pick throughout Renames all files, classes, types, and UI labels introduced by the quick-pick feature from "Common" to "Quick-Pick": - CommonBoluses/ → QuickPickBoluses/ - CommonMeals/ → QuickPickMeals/ - CommonBolusesManager → QuickPickBolusesManager - CommonMealsManager → QuickPickMealsManager - CommonSectionHeader → QuickPickSectionHeader - UI labels: "Common Boluses/Meals" → "Quick-Pick Boluses/Meals" --- LoopFollow.xcodeproj/project.pbxproj | 36 +++++ .../Views/QuickPickSectionHeader.swift | 69 +++++++++ .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 33 +++++ .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 35 +++++ .../QuickPickBolusesManager.swift | 133 +++++++++++++++++ .../RemoteBolusHistoryEntry.swift | 27 ++++ .../QuickPickMealsManager.swift | 139 ++++++++++++++++++ .../RemoteMealHistoryEntry.swift | 39 +++++ LoopFollow/Remote/TRC/BolusView.swift | 42 ++++++ LoopFollow/Remote/TRC/MealView.swift | 64 ++++++++ LoopFollow/Storage/Storage.swift | 5 + 11 files changed, 622 insertions(+) create mode 100644 LoopFollow/Helpers/Views/QuickPickSectionHeader.swift create mode 100644 LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift create mode 100644 LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift create mode 100644 LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift create mode 100644 LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 34edc6b79..39e91c4d4 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; + B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; + B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; + B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; @@ -100,6 +104,7 @@ DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; + B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000C1 /* QuickPickSectionHeader.swift */; }; DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; }; CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; }; DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */; }; @@ -469,6 +474,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBolusHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000A3 /* QuickPickBolusesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickBolusesManager.swift; sourceTree = ""; }; + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMealHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000B3 /* QuickPickMealsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickMealsManager.swift; sourceTree = ""; }; 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; @@ -559,6 +568,7 @@ DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; + B500000000000000000000C1 /* QuickPickSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickSectionHeader.swift; sourceTree = ""; }; DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = ""; }; DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGDisplayView.swift; sourceTree = ""; }; @@ -1056,9 +1066,29 @@ path = Metric; sourceTree = ""; }; + B500000000000000000000B5 /* QuickPickMeals */ = { + isa = PBXGroup; + children = ( + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */, + B500000000000000000000B3 /* QuickPickMealsManager.swift */, + ); + path = QuickPickMeals; + sourceTree = ""; + }; + B500000000000000000000A5 /* QuickPickBoluses */ = { + isa = PBXGroup; + children = ( + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */, + B500000000000000000000A3 /* QuickPickBolusesManager.swift */, + ); + path = QuickPickBoluses; + sourceTree = ""; + }; DD0C0C6E2C4AFFB800DBADDF /* Remote */ = { isa = PBXGroup; children = ( + B500000000000000000000A5 /* QuickPickBoluses */, + B500000000000000000000B5 /* QuickPickMeals */, DDDF6F4A2D479B6A00884336 /* Nightscout */, DDDF6F482D479AEF00884336 /* NoRemoteView.swift */, DDEF503E2D479B8A00884336 /* LoopAPNS */, @@ -1414,6 +1444,7 @@ DDF6999D2C5AAA640058A8D9 /* ErrorMessageView.swift */, DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */, DD16AF102C997B4600FB655A /* LoadingButtonView.swift */, + B500000000000000000000C1 /* QuickPickSectionHeader.swift */, DDE75D262DE5E539007C1FC1 /* ActionRow.swift */, 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */, DDE75D282DE5E56C007C1FC1 /* LinkRow.swift */, @@ -2189,6 +2220,7 @@ DDC6CA4B2DD8E4960060EE25 /* PumpVolumeAlarmEditor.swift in Sources */, 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */, DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */, + B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */, DDFF3D7F2D1414A200BF9D9E /* BLEDevice.swift in Sources */, DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */, FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */, @@ -2215,6 +2247,10 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */, DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */, DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, + B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */, + B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */, + B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */, + B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, diff --git a/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift b/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift new file mode 100644 index 000000000..e966de81d --- /dev/null +++ b/LoopFollow/Helpers/Views/QuickPickSectionHeader.swift @@ -0,0 +1,69 @@ +// LoopFollow +// QuickPickSectionHeader.swift + +import SwiftUI + +struct QuickPickSectionHeader: View { + let title: String + let infoText: String + @State private var showInfo = false + + var body: some View { + HStack(spacing: 4) { + Text(title) + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + .sheet(isPresented: $showInfo) { + QuickPickInfoSheet(title: title, text: infoText) + } + } +} + +private struct QuickPickInfoSheet: View { + let title: String + let text: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + Text(text) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } +} + +extension QuickPickSectionHeader { + static let bolusInfoText = """ + These buttons show your most-used recent bolus amounts. + + They're based on what you've sent before at similar times on similar days — so if you usually give 4 units before breakfast on weekdays, that button will show up on weekday mornings. + + Tap a button to fill in the amount. Nothing is sent until you review and confirm. + """ + + static let mealInfoText = """ + These buttons show your most-used recent meals. + + They're based on what you've sent before at similar times on similar days — so if you usually send the same breakfast on weekday mornings, it'll appear as an option. + + Tap a button to fill in the details. Nothing is sent until you review and confirm. + """ +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index c78a655f9..2cc834fc3 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -13,6 +13,7 @@ struct LoopAPNSBolusView: View { @State private var alertMessage = "" @State private var alertType: AlertType = .success + @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var insulinFieldIsFocused: Bool // Add state for recommended bolus and warning @@ -72,6 +73,30 @@ struct LoopAPNSBolusView: View { } } + if !quickPickBoluses.quickPickBoluses.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickBoluses.quickPickBoluses) { bolus in + Button { + insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: bolus.units) + } label: { + Text("\(InsulinFormatter.shared.string(bolus.units))U") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Insulin Amount", @@ -186,6 +211,10 @@ struct LoopAPNSBolusView: View { showAlert = true } + let step = Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()) + let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + quickPickBoluses.refresh(stepIncrement: max(0.001, step), maxBolus: maxBolus) + loadRecommendedBolus() // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil @@ -374,6 +403,10 @@ struct LoopAPNSBolusView: View { DispatchQueue.main.async { self.isLoading = false if success { + let sentUnits = insulinAmount.doubleValue(for: .internationalUnit()) + if sentUnits > 0 { + QuickPickBolusesManager.shared.recordBolus(units: sentUnits) + } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) self.alertMessage = "Insulin sent successfully!" diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 50b47dcd7..32db0f9c9 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -8,6 +8,7 @@ struct LoopAPNSCarbsView: View { private typealias AbsorptionPreset = (hours: Int, minutes: Int) @Environment(\.presentationMode) var presentationMode + @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var absorptionHours = 3 @State private var absorptionMinutes = 0 @@ -102,6 +103,30 @@ struct LoopAPNSCarbsView: View { NavigationView { VStack { Form { + if !quickPickMeals.quickPickMeals.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickMeals.quickPickMeals) { meal in + Button { + carbsAmount = HKQuantity(unit: .gram(), doubleValue: meal.carbs) + } label: { + Text("\(Int(meal.carbs))g") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Carbs Amount", @@ -384,6 +409,12 @@ struct LoopAPNSCarbsView: View { alertType = .error showAlert = true } + + quickPickMeals.refresh( + maxCarbs: Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + includeFatProtein: false + ) + // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil // Don't reset TOTP usage flag here - let the timer handle it @@ -534,6 +565,10 @@ struct LoopAPNSCarbsView: View { DispatchQueue.main.async { self.isLoading = false if success { + let sentCarbs = carbsAmount.doubleValue(for: .gram()) + if sentCarbs > 0 { + QuickPickMealsManager.shared.recordMeal(carbs: sentCarbs) + } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) let timeFormatter = DateFormatter() diff --git a/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift b/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift new file mode 100644 index 000000000..84c2c6ab3 --- /dev/null +++ b/LoopFollow/Remote/QuickPickBoluses/QuickPickBolusesManager.swift @@ -0,0 +1,133 @@ +// LoopFollow +// QuickPickBolusesManager.swift + +import Foundation + +struct QuickPickBolus: Identifiable, Equatable { + let id = UUID() + let units: Double + + static func == (lhs: QuickPickBolus, rhs: QuickPickBolus) -> Bool { + lhs.id == rhs.id + } +} + +final class QuickPickBolusesManager: ObservableObject { + static let shared = QuickPickBolusesManager() + + @Published private(set) var quickPickBoluses: [QuickPickBolus] = [] + + private static let maxEntries = 500 + private static let maxAgeDays = 90.0 + private static let sigma: Double = 60.0 + private static let halfLife: Double = 10.0 + private static let minScore: Double = 0.1 + private static let maxResults = 5 + + private init() {} + + // MARK: - Public API + + func recordBolus(units: Double, at date: Date = Date()) { + let entry = RemoteBolusHistoryEntry(units: units, date: date) + var history = Storage.shared.remoteBolusHistory.value + history.append(entry) + history = Self.pruned(history, now: date) + Storage.shared.remoteBolusHistory.value = history + } + + func refresh(now: Date = Date(), stepIncrement: Double, maxBolus: Double) { + let history = Storage.shared.remoteBolusHistory.value + quickPickBoluses = Self.computeQuickPickBoluses( + from: history, + now: now, + stepIncrement: stepIncrement, + maxBolus: maxBolus + ) + } + + // MARK: - Scoring (static for testability) + + static func computeQuickPickBoluses( + from history: [RemoteBolusHistoryEntry], + now: Date, + stepIncrement: Double, + maxBolus: Double + ) -> [QuickPickBolus] { + guard stepIncrement > 0 else { return [] } + + let nowMinute = { + let cal = Calendar.current + return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + }() + let nowDOW = Calendar.current.component(.weekday, from: now) + + var groups: [Double: Double] = [:] + + for entry in history { + let rounded = (entry.units / stepIncrement).rounded(.down) * stepIncrement + let amount = roundToFraction(rounded, stepIncrement: stepIncrement) + guard amount > 0, amount <= maxBolus else { continue } + + let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute) + let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW) + let daysAgo = now.timeIntervalSince(entry.date) / 86400.0 + let r = recencyScore(daysAgo: daysAgo) + + groups[amount, default: 0] += t * d * r + } + + return groups + .filter { $0.value >= minScore } + .sorted { $0.value > $1.value } + .prefix(maxResults) + .map { QuickPickBolus(units: $0.key) } + } + + static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double { + let diff = abs(entryMinute - nowMinute) + let circularDiff = Double(min(diff, 1440 - diff)) + return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma)) + } + + static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double { + if entryDOW == nowDOW { return 1.0 } + let nowWeekend = nowDOW == 1 || nowDOW == 7 + let entryWeekend = entryDOW == 1 || entryDOW == 7 + if nowWeekend == entryWeekend { return 0.7 } + return 0.15 + } + + static func recencyScore(daysAgo: Double) -> Double { + pow(0.5, daysAgo / halfLife) + } + + // MARK: - Helpers + + private static func pruned(_ history: [RemoteBolusHistoryEntry], now: Date) -> [RemoteBolusHistoryEntry] { + let cutoff = now.addingTimeInterval(-maxAgeDays * 86400) + var filtered = history.filter { $0.date > cutoff } + if filtered.count > maxEntries { + filtered.sort { $0.date > $1.date } + filtered = Array(filtered.prefix(maxEntries)) + } + return filtered + } + + private static func roundToFraction(_ value: Double, stepIncrement: Double) -> Double { + let digits = fractionDigits(for: stepIncrement) + let p = pow(10.0, Double(digits)) + return (value * p).rounded() / p + } + + private static func fractionDigits(for step: Double) -> Int { + if step >= 1 { return 0 } + var v = step + var digits = 0 + while digits < 6, abs(v.rounded() - v) > 1e-10 { + v *= 10 + digits += 1 + } + return min(max(digits, 0), 5) + } +} diff --git a/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift b/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift new file mode 100644 index 000000000..896b7459b --- /dev/null +++ b/LoopFollow/Remote/QuickPickBoluses/RemoteBolusHistoryEntry.swift @@ -0,0 +1,27 @@ +// LoopFollow +// RemoteBolusHistoryEntry.swift + +import Foundation + +/// A record of a remotely-sent bolus, stored locally for pattern-based suggestions. +struct RemoteBolusHistoryEntry: Codable, Equatable { + /// Bolus amount in international units + let units: Double + + /// When the bolus was sent + let date: Date + + /// Day of week: 1=Sunday ... 7=Saturday (Calendar.component(.weekday)) + let dayOfWeek: Int + + /// Minute of day: 0...1439 (hour * 60 + minute) + let minuteOfDay: Int + + init(units: Double, date: Date) { + self.units = units + self.date = date + let cal = Calendar.current + dayOfWeek = cal.component(.weekday, from: date) + minuteOfDay = cal.component(.hour, from: date) * 60 + cal.component(.minute, from: date) + } +} diff --git a/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift b/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift new file mode 100644 index 000000000..cd3f4de25 --- /dev/null +++ b/LoopFollow/Remote/QuickPickMeals/QuickPickMealsManager.swift @@ -0,0 +1,139 @@ +// LoopFollow +// QuickPickMealsManager.swift + +import Foundation + +struct QuickPickMeal: Identifiable, Equatable { + let id = UUID() + let carbs: Double + let fat: Double + let protein: Double + let bolus: Double + + static func == (lhs: QuickPickMeal, rhs: QuickPickMeal) -> Bool { + lhs.id == rhs.id + } +} + +final class QuickPickMealsManager: ObservableObject { + static let shared = QuickPickMealsManager() + + @Published private(set) var quickPickMeals: [QuickPickMeal] = [] + + private static let maxEntries = 500 + private static let maxAgeDays = 90.0 + private static let sigma: Double = 60.0 + private static let halfLife: Double = 10.0 + private static let minScore: Double = 0.1 + private static let maxResults = 5 + + private init() {} + + // MARK: - Public API + + func recordMeal(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, at date: Date = Date()) { + let entry = RemoteMealHistoryEntry(carbs: carbs, fat: fat, protein: protein, bolus: bolus, date: date) + var history = Storage.shared.remoteMealHistory.value + history.append(entry) + history = Self.pruned(history, now: date) + Storage.shared.remoteMealHistory.value = history + } + + func refresh(now: Date = Date(), carbStep: Double = 1.0, maxCarbs: Double, includeFatProtein: Bool) { + let history = Storage.shared.remoteMealHistory.value + quickPickMeals = Self.computeQuickPickMeals( + from: history, + now: now, + carbStep: carbStep, + maxCarbs: maxCarbs, + includeFatProtein: includeFatProtein + ) + } + + // MARK: - Scoring (static for testability) + + static func computeQuickPickMeals( + from history: [RemoteMealHistoryEntry], + now: Date, + carbStep: Double, + maxCarbs: Double, + includeFatProtein: Bool + ) -> [QuickPickMeal] { + guard carbStep > 0 else { return [] } + + let nowMinute = { + let cal = Calendar.current + return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) + }() + let nowDOW = Calendar.current.component(.weekday, from: now) + + // Group by rounded carbs; track score + best entry (highest scored) for fat/protein + var groupScores: [Double: Double] = [:] + var groupBestEntry: [Double: (entry: RemoteMealHistoryEntry, score: Double)] = [:] + + for entry in history { + let rounded = (entry.carbs / carbStep).rounded() * carbStep + guard rounded > 0, rounded <= maxCarbs else { continue } + + let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute) + let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW) + let daysAgo = now.timeIntervalSince(entry.date) / 86400.0 + let r = recencyScore(daysAgo: daysAgo) + + let score = t * d * r + groupScores[rounded, default: 0] += score + + if let current = groupBestEntry[rounded] { + if score > current.score { + groupBestEntry[rounded] = (entry, score) + } + } else { + groupBestEntry[rounded] = (entry, score) + } + } + + return groupScores + .filter { $0.value >= minScore } + .sorted { $0.value > $1.value } + .prefix(maxResults) + .map { item in + let best = groupBestEntry[item.key]?.entry + return QuickPickMeal( + carbs: item.key, + fat: includeFatProtein ? (best?.fat ?? 0) : 0, + protein: includeFatProtein ? (best?.protein ?? 0) : 0, + bolus: best?.bolus ?? 0 + ) + } + } + + static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double { + let diff = abs(entryMinute - nowMinute) + let circularDiff = Double(min(diff, 1440 - diff)) + return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma)) + } + + static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double { + if entryDOW == nowDOW { return 1.0 } + let nowWeekend = nowDOW == 1 || nowDOW == 7 + let entryWeekend = entryDOW == 1 || entryDOW == 7 + if nowWeekend == entryWeekend { return 0.7 } + return 0.15 + } + + static func recencyScore(daysAgo: Double) -> Double { + pow(0.5, daysAgo / halfLife) + } + + // MARK: - Helpers + + private static func pruned(_ history: [RemoteMealHistoryEntry], now: Date) -> [RemoteMealHistoryEntry] { + let cutoff = now.addingTimeInterval(-maxAgeDays * 86400) + var filtered = history.filter { $0.date > cutoff } + if filtered.count > maxEntries { + filtered.sort { $0.date > $1.date } + filtered = Array(filtered.prefix(maxEntries)) + } + return filtered + } +} diff --git a/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift b/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift new file mode 100644 index 000000000..65ffb1e31 --- /dev/null +++ b/LoopFollow/Remote/QuickPickMeals/RemoteMealHistoryEntry.swift @@ -0,0 +1,39 @@ +// LoopFollow +// RemoteMealHistoryEntry.swift + +import Foundation + +/// A record of a remotely-sent meal, stored locally for pattern-based common meals. +struct RemoteMealHistoryEntry: Codable, Equatable { + /// Carbs in grams + let carbs: Double + + /// Fat in grams (0 if not applicable) + let fat: Double + + /// Protein in grams (0 if not applicable) + let protein: Double + + /// Bolus in units (0 if no bolus with meal) + let bolus: Double + + /// When the meal was sent + let date: Date + + /// Day of week: 1=Sunday ... 7=Saturday (Calendar.component(.weekday)) + let dayOfWeek: Int + + /// Minute of day: 0...1439 (hour * 60 + minute) + let minuteOfDay: Int + + init(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, date: Date = Date()) { + self.carbs = carbs + self.fat = fat + self.protein = protein + self.bolus = bolus + self.date = date + let cal = Calendar.current + dayOfWeek = cal.component(.weekday, from: date) + minuteOfDay = cal.component(.hour, from: date) * 60 + cal.component(.minute, from: date) + } +} diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 28fbb30a1..7a59c87de 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -14,6 +14,7 @@ struct BolusView: View { @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested + @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var bolusFieldIsFocused: Bool @State private var showAlert = false @@ -67,6 +68,30 @@ struct BolusView: View { Form { recommendedBlocks(now: context.date) + if !quickPickBoluses.quickPickBoluses.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickBoluses.quickPickBoluses) { bolus in + Button { + applyQuickPickBolus(bolus.units) + } label: { + Text("\(InsulinFormatter.shared.string(bolus.units))U") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section { HKQuantityInputView( label: "Bolus Amount", @@ -118,6 +143,12 @@ struct BolusView: View { .navigationTitle("Bolus") .navigationBarTitleDisplayMode(.inline) } + .onAppear { + quickPickBoluses.refresh( + stepIncrement: stepU, + maxBolus: maxBolus.value.doubleValue(for: .internationalUnit()) + ) + } .alert(isPresented: $showAlert) { switch alertType { case .confirmBolus: @@ -258,6 +289,13 @@ struct BolusView: View { } } + private func applyQuickPickBolus(_ units: Double) { + let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) + let clamped = min(units, maxU) + let stepped = roundedToStep(clamped) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped) + } + private func applyRecommendedBolus(_ rec: Double) { let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) let clamped = min(rec, maxU) @@ -280,6 +318,10 @@ struct BolusView: View { DispatchQueue.main.async { isLoading = false if success { + let sentUnits = bolusAmount.doubleValue(for: .internationalUnit()) + if sentUnits > 0 { + QuickPickBolusesManager.shared.recordBolus(units: sentUnits) + } statusMessage = "Bolus command sent successfully." LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index ec35e508c..5938735d1 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -20,6 +20,7 @@ struct MealView: View { @ObservedObject private var mealWithBolus = Storage.shared.mealWithBolus @ObservedObject private var mealWithFatProtein = Storage.shared.mealWithFatProtein @ObservedObject private var maxBolus = Storage.shared.maxBolus + @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared @FocusState private var carbsFieldIsFocused: Bool @FocusState private var proteinFieldIsFocused: Bool @@ -46,6 +47,40 @@ struct MealView: View { NavigationView { VStack { Form { + if !quickPickMeals.quickPickMeals.isEmpty { + Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(quickPickMeals.quickPickMeals) { meal in + Button { + applyQuickPickMeal(meal) + } label: { + VStack(spacing: 2) { + Text("\(Int(meal.carbs))g") + .font(.subheadline.weight(.medium)) + if mealWithFatProtein.value, meal.fat > 0 || meal.protein > 0 { + Text("F\(Int(meal.fat)) P\(Int(meal.protein))") + .font(.caption2) + } + if mealWithBolus.value, meal.bolus > 0 { + Text("\(InsulinFormatter.shared.string(meal.bolus))U") + .font(.caption2) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + } + Section(header: Text("Meal Data")) { // TODO: This banner can be deleted in March 2027. Check the commit for other places to cleanup. if showFatProteinOrderBanner { @@ -186,6 +221,11 @@ struct MealView: View { selectedTime = nil isScheduling = false + quickPickMeals.refresh( + maxCarbs: maxCarbs.value.doubleValue(for: .gram()), + includeFatProtein: mealWithFatProtein.value + ) + if !Storage.shared.hasSeenFatProteinOrderChange.value && Storage.shared.mealWithFatProtein.value { showFatProteinOrderBanner = true } @@ -313,6 +353,15 @@ struct MealView: View { DispatchQueue.main.async { isLoading = false if success { + let sentCarbs = carbs.doubleValue(for: .gram()) + if sentCarbs > 0 { + QuickPickMealsManager.shared.recordMeal( + carbs: sentCarbs, + fat: fat.doubleValue(for: .gram()), + protein: protein.doubleValue(for: .gram()), + bolus: bolusAmount.doubleValue(for: .internationalUnit()) + ) + } statusMessage = "Meal command sent successfully." LogManager.shared.log( category: .apns, @@ -345,6 +394,21 @@ struct MealView: View { return formatter.string(from: date) } + private func applyQuickPickMeal(_ meal: QuickPickMeal) { + let maxC = maxCarbs.value.doubleValue(for: .gram()) + carbs = HKQuantity(unit: .gram(), doubleValue: min(meal.carbs, maxC)) + if mealWithFatProtein.value { + let maxF = maxFat.value.doubleValue(for: .gram()) + let maxP = maxProtein.value.doubleValue(for: .gram()) + fat = HKQuantity(unit: .gram(), doubleValue: min(meal.fat, maxF)) + protein = HKQuantity(unit: .gram(), doubleValue: min(meal.protein, maxP)) + } + if mealWithBolus.value, meal.bolus > 0 { + let maxB = maxBolus.value.doubleValue(for: .internationalUnit()) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: min(meal.bolus, maxB)) + } + } + private func handleValidationError(_ message: String) { alertMessage = message alertType = .validationError diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 6ee01522b..7eb55888b 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -235,6 +235,9 @@ class Storage { var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) + + var remoteBolusHistory = StorageValue<[RemoteBolusHistoryEntry]>(key: "remoteBolusHistory", defaultValue: []) + var remoteMealHistory = StorageValue<[RemoteMealHistoryEntry]>(key: "remoteMealHistory", defaultValue: []) // Statistics display preferences var showGMI = StorageValue(key: "showGMI", defaultValue: true) var showStdDev = StorageValue(key: "showStdDev", defaultValue: true) @@ -429,6 +432,8 @@ class Storage { loopAPNSQrCodeURL.reload() bolusIncrementDetected.reload() + remoteBolusHistory.reload() + remoteMealHistory.reload() showGMI.reload() showStdDev.reload() showTITR.reload() From 350e3af473febf3cac692cfd6c8d2f705b6cdc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 22:07:03 +0200 Subject: [PATCH 07/22] Add Nightscout WebSocket support for real-time data updates (#606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Nightscout WebSocket (Socket.IO) support for real-time data updates Connect to Nightscout's Socket.IO endpoint to receive push notifications when new data arrives, instead of waiting for the next poll cycle. The WebSocket acts as a smart trigger: when a dataUpdate event arrives, only the relevant data types (BG, treatments, device status, profile) are fetched based on which keys are present in the delta payload. When WebSocket is connected and authenticated, polling intervals are extended (BG 3x, device status 3x, treatments 2→10 min, profile 10→30 min) so HTTP polling becomes a safety net. On disconnect, polling immediately reverts to normal intervals. The feature is always-on when Nightscout is configured — no user setting needed. A read-only connection status is shown in Nightscout settings. - Add Socket.IO-Client-Swift 16.1.1 via SPM - Add NightscoutSocketManager for connection lifecycle - Add NightscoutSocketDataHandler for selective push-trigger logic - Extend polling intervals when WebSocket is authenticated - Show WebSocket status in Nightscout settings - Wire up lifecycle in MainViewController (init, foreground, refresh) - Add staleness detection (10 min fallback to polling) * Add opt-in WebSocket toggle with info sheet and refresh on disconnect - Add webSocketEnabled storage property (default off) so users opt in - Replace read-only status with toggle + info button in Nightscout settings - Info sheet explains real-time updates, polling fallback, and battery impact - On toggle off: disconnect socket and trigger full refresh to restore normal polling intervals immediately - On unexpected socket disconnect: post refresh notification so extended polling intervals revert to normal without waiting for them to expire * Fix stale WebSocket session on config change, remove redundant staleness timer - Disconnect WebSocket when Nightscout URL/token validation fails, preventing the old session from streaming data from a previous server while polling has switched to the new config - Reorder removeAllHandlers() before disconnect() so intentional disconnects don't fire the event handler that would reconnect to an invalid URL - Remove staleness timer — the extended polling intervals (3x/5x) already serve as the safety net when WebSocket data stops flowing * Limit WebSocket to foreground and default-on Disconnect the Nightscout WebSocket when LoopFollow moves to the background and re-establish it when returning to the foreground. Polling continues to handle background updates, so the persistent connection no longer holds the cellular radio out of idle while the app is not in use. With the battery cost bounded to foreground time, the toggle now defaults to on. Inline documentation updated to describe the foreground/background behavior. * Resume Nightscout polling promptly when the WebSocket disconnects While the socket is authenticated, each REST poll is rescheduled with a multiplier on the assumption WS will publish the next reading before the poll fires. Without an explicit catch-up, those long delays carry over when the socket goes away — most visibly on background entry, where the user could sit on stale data for 10–15 minutes after the screen locked. Fire each Nightscout poll immediately on disconnect. Their actions then reschedule on the normal un-multiplied cadence, since connectionState is no longer .authenticated. Gate on the previous state so the reconnect dance in connectIfNeeded() and other no-op disconnect paths don't trigger spurious REST round-trips. --- LoopFollow.xcodeproj/project.pbxproj | 31 +++ .../Controllers/Nightscout/BGData.swift | 4 + .../Controllers/Nightscout/DeviceStatus.swift | 39 ++-- .../NightscoutSocketDataHandler.swift | 54 +++++ .../Nightscout/NightscoutSocketManager.swift | 198 ++++++++++++++++++ LoopFollow/Log/LogManager.swift | 1 + .../Nightscout/NightscoutSettingsView.swift | 54 +++++ .../NightscoutSettingsViewModel.swift | 50 +++++ LoopFollow/Storage/Storage.swift | 1 + LoopFollow/Task/ProfileTask.swift | 6 +- LoopFollow/Task/TreatmentsTask.swift | 6 +- .../ViewControllers/MainViewController.swift | 5 + 12 files changed, 423 insertions(+), 26 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift create mode 100644 LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 39e91c4d4..4fbee199b 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -440,6 +440,9 @@ FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; }; + DD50C10A2F60A00000000001 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = DD50C10A2F60A00000000003 /* SocketIO */; }; + DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */; }; + DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -604,6 +607,8 @@ DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = ""; }; DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = ""; }; DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = ""; }; + DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketManager.swift; sourceTree = ""; }; + DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketDataHandler.swift; sourceTree = ""; }; DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = ""; }; DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = ""; }; @@ -941,6 +946,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DD50C10A2F60A00000000001 /* SocketIO in Frameworks */, FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, ); @@ -1209,6 +1215,8 @@ DD0C0C5F2C415B9D00DBADDF /* ProfileManager.swift */, DD0C0C612C4175FD00DBADDF /* NSProfile.swift */, DD5334222C60ED3600062F9D /* IAge.swift */, + DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */, + DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */, ); path = Nightscout; sourceTree = ""; @@ -1844,6 +1852,7 @@ ); name = LoopFollow; packageProductDependencies = ( + DD50C10A2F60A00000000003 /* SocketIO */, ); productName = LoopFollow; productReference = FC9788142485969B00A7906C /* Loop Follow.app */; @@ -1881,6 +1890,7 @@ ); mainGroup = FC97880B2485969B00A7906C; packageReferences = ( + DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; @@ -2255,6 +2265,8 @@ DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, + DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */, + DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */, 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */, @@ -2870,6 +2882,25 @@ versionGroupType = wrapper.xcdatamodel; }; /* End XCVersionGroup section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/socketio/socket.io-client-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 16.1.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DD50C10A2F60A00000000003 /* SocketIO */ = { + isa = XCSwiftPackageProductDependency; + package = DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */; + productName = SocketIO; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = FC97880C2485969B00A7906C /* Project object */; } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index b39e46520..dbdd27afc 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -186,6 +186,10 @@ extension MainViewController { TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } + if NightscoutSocketManager.shared.connectionState == .authenticated { + delayToSchedule = max(delayToSchedule * 3, 60) + } + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date().addingTimeInterval(delayToSchedule)) // Evaluate speak conditions if there is a previous value. diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 7a3935045..d2d6920d8 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -193,37 +193,28 @@ extension MainViewController { let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0) DispatchQueue.main.async { + var interval: Double if secondsAgo >= (20 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(5 * 60) - ) - + interval = 5 * 60 } else if secondsAgo >= (10 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(60) - ) - + interval = 60 } else if secondsAgo >= (7 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(30) - ) - + interval = 30 } else if secondsAgo >= (5 * 60) { - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(10) - ) + interval = 10 } else { - let interval = (310 - secondsAgo) - TaskScheduler.shared.rescheduleTask( - id: .deviceStatus, - to: Date().addingTimeInterval(interval) - ) + interval = 310 - secondsAgo TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } + + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = max(interval * 3, 60) + } + + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(interval) + ) } evaluateNotLooping() diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift new file mode 100644 index 000000000..054491c68 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketDataHandler.swift @@ -0,0 +1,54 @@ +// LoopFollow +// NightscoutSocketDataHandler.swift + +import Foundation + +extension MainViewController { + func setupNightscoutSocket() { + NightscoutSocketManager.shared.onDataUpdate = { [weak self] data in + self?.handleSocketDataUpdate(data) + } + NightscoutSocketManager.shared.connectIfNeeded() + } + + func handleSocketDataUpdate(_ data: [String: Any]) { + let isDelta = data["delta"] as? Bool ?? false + + if !isDelta { + // Full data on initial connect — trigger all fetches + LogManager.shared.log(category: .websocket, message: "Full data received, triggering all fetches") + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date()) + return + } + + // Selective: only fetch data types present in the delta + var triggered: [String] = [] + + if data["sgvs"] != nil || data["mbgs"] != nil { + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + triggered.append("BG") + } + + if data["devicestatus"] != nil { + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + triggered.append("DeviceStatus") + } + + if data["treatments"] != nil { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + triggered.append("Treatments") + } + + if data["profiles"] != nil { + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date()) + triggered.append("Profile") + } + + if !triggered.isEmpty { + LogManager.shared.log(category: .websocket, message: "Delta triggered: \(triggered.joined(separator: ", "))", isDebug: true) + } + } +} diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift new file mode 100644 index 000000000..2508c94e2 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift @@ -0,0 +1,198 @@ +// LoopFollow +// NightscoutSocketManager.swift + +import Foundation +import SocketIO + +class NightscoutSocketManager { + static let shared = NightscoutSocketManager() + + enum ConnectionState: String { + case disconnected + case connecting + case connected + case authenticated + case error + } + + private(set) var connectionState: ConnectionState = .disconnected { + didSet { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .nightscoutSocketStateChanged, object: nil) + } + } + } + + private var manager: SocketManager? + private var socket: SocketIOClient? + private var currentURL: String = "" + private var currentToken: String = "" + + var onDataUpdate: (([String: Any]) -> Void)? + + private init() {} + + // MARK: - Public API + + func connectIfNeeded() { + guard Storage.shared.webSocketEnabled.value else { + disconnect() + return + } + + let url = Storage.shared.url.value + let token = Storage.shared.token.value + + guard !url.isEmpty else { + disconnect() + return + } + + // Already connected to the same URL + if connectionState == .authenticated || connectionState == .connecting || connectionState == .connected { + if url == currentURL, token == currentToken { + return + } + // URL or token changed, reconnect + disconnect() + } + + currentURL = url + currentToken = token + connect() + } + + func disconnect() { + let wasAuthenticated = connectionState == .authenticated + + socket?.removeAllHandlers() + socket?.disconnect() + manager?.disconnect() + manager = nil + socket = nil + connectionState = .disconnected + currentURL = "" + currentToken = "" + + // While WS was delivering, each Nightscout poll was rescheduled with a multiplier on + // the assumption WS would publish before the next REST tick. With WS gone, those + // long delays would leave the user on stale data for ~10–15 minutes. Fire each poll + // now; their actions will reschedule on the normal (un-multiplied) cadence going + // forward, since connectionState is no longer .authenticated. + // + // Only catch up if we were actually authenticated — skip the no-op disconnect paths + // (already-disconnected, never-connected) and the connectIfNeeded() reconnect dance, + // where polls were already on a sensible schedule. + if wasAuthenticated { + let now = Date() + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: now) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: now) + TaskScheduler.shared.rescheduleTask(id: .treatments, to: now) + TaskScheduler.shared.rescheduleTask(id: .profile, to: now) + } + } + + // MARK: - Private + + private func connect() { + guard let url = URL(string: currentURL) else { + LogManager.shared.log(category: .websocket, message: "Invalid Nightscout URL for WebSocket") + connectionState = .error + return + } + + connectionState = .connecting + + var config: SocketIOClientConfiguration = [ + .log(false), + .compress, + .forceWebsockets(false), + .reconnects(true), + .reconnectWait(5), + .reconnectWaitMax(30), + ] + + if !currentToken.isEmpty { + config.insert(.connectParams(["token": currentToken])) + } + + manager = SocketManager(socketURL: url, config: config) + + guard let mgr = manager else { return } + socket = mgr.defaultSocket + + setupEventHandlers() + socket?.connect() + + LogManager.shared.log(category: .websocket, message: "Connecting to Nightscout WebSocket at \(currentURL)") + } + + private func setupEventHandlers() { + guard let socket = socket else { return } + + socket.on(clientEvent: .connect) { [weak self] _, _ in + guard let self = self else { return } + LogManager.shared.log(category: .websocket, message: "Socket connected, authorizing...") + self.connectionState = .connected + self.authorize() + } + + socket.on(clientEvent: .disconnect) { [weak self] data, _ in + guard let self = self else { return } + let reason = (data.first as? String) ?? "unknown" + LogManager.shared.log(category: .websocket, message: "Socket disconnected: \(reason)") + self.connectionState = .disconnected + // Immediately restore normal polling intervals + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + socket.on(clientEvent: .reconnect) { _, _ in + LogManager.shared.log(category: .websocket, message: "Socket reconnecting...") + } + + socket.on(clientEvent: .error) { [weak self] data, _ in + let errorMsg = (data.first as? String) ?? "unknown error" + LogManager.shared.log(category: .websocket, message: "Socket error: \(errorMsg)") + self?.connectionState = .error + } + + socket.on("connected") { [weak self] _, _ in + guard let self = self else { return } + LogManager.shared.log(category: .websocket, message: "Authorized and receiving data") + self.connectionState = .authenticated + } + + socket.on("dataUpdate") { [weak self] data, _ in + guard let self = self, + let payload = data.first as? [String: Any] + else { return } + + LogManager.shared.log(category: .websocket, message: "Received dataUpdate (delta: \(payload["delta"] as? Bool ?? false))", isDebug: true) + + DispatchQueue.main.async { + self.onDataUpdate?(payload) + } + } + } + + private func authorize() { + var authPayload: [String: Any] = [ + "client": "LoopFollow", + "history": 1, + ] + + // Nightscout's authorization.resolve() expects: + // - "token" field for JWT tokens (verified via verifyJWT) + // - "secret" field for access tokens (checked via doesAccessTokenExist) + // LoopFollow uses access tokens (e.g. "readable-xxxx"), so pass as "secret". + if !currentToken.isEmpty { + authPayload["secret"] = currentToken + } + + socket?.emit("authorize", authPayload) + } +} + +extension Notification.Name { + static let nightscoutSocketStateChanged = Notification.Name("nightscoutSocketStateChanged") +} diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 094fdde26..5bb40eeb6 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -30,6 +30,7 @@ class LogManager { case calendar = "Calendar" case deviceStatus = "Device Status" case remote = "Remote" + case websocket = "WebSocket" case telemetry = "Telemetry" } diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index e1a9c00ef..a5245e23c 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -16,6 +16,7 @@ struct NightscoutSettingsView: View { urlSection tokenSection statusSection + webSocketSection if viewModel.isFreshSetup { continueSection @@ -96,6 +97,59 @@ struct NightscoutSettingsView: View { } } + @State private var showWebSocketInfo = false + + private var webSocketSection: some View { + Section(header: webSocketSectionHeader) { + Toggle("Enable WebSocket", isOn: $viewModel.webSocketEnabled) + if viewModel.webSocketEnabled { + HStack { + Text("Status") + Spacer() + Text(viewModel.webSocketStatus) + .foregroundColor(viewModel.webSocketStatusColor) + } + } + } + .sheet(isPresented: $showWebSocketInfo) { + NavigationStack { + ScrollView { + Text(""" + When enabled, LoopFollow maintains a live connection to your Nightscout server using WebSocket while the app is in the foreground. Data updates (new glucose readings, treatments, device status) arrive within seconds instead of waiting for the next polling cycle. + + The WebSocket disconnects when LoopFollow moves to the background and reconnects when you return to the app. Polling continues to handle updates while the app is in the background. + + In the foreground, polling continues at a reduced frequency as a safety net. If the WebSocket connection drops, normal polling resumes immediately. + """) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Real-time Updates") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showWebSocketInfo = false } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } + + private var webSocketSectionHeader: some View { + HStack(spacing: 4) { + Text("Real-time Updates") + Button { + showWebSocketInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + } + private var importSection: some View { Section(header: Text("Import Settings")) { if let onImportSettings { diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index e3a778a7f..559a12916 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -3,6 +3,7 @@ import Combine import Foundation +import SwiftUI class NightscoutSettingsViewModel: ObservableObject { private var initialURL: String @@ -34,6 +35,29 @@ class NightscoutSettingsViewModel: ObservableObject { @Published var nightscoutStatus: String = "Checking..." + @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value { + didSet { + Storage.shared.webSocketEnabled.value = webSocketEnabled + if webSocketEnabled { + NightscoutSocketManager.shared.connectIfNeeded() + } else { + NightscoutSocketManager.shared.disconnect() + triggerRefresh() + } + } + } + + @Published var webSocketStatus: String = "Disconnected" + + var webSocketStatusColor: Color { + switch NightscoutSocketManager.shared.connectionState { + case .authenticated: return .green + case .connecting, .connected: return .orange + case .disconnected: return .secondary + case .error: return .red + } + } + private var cancellables = Set() private var checkStatusSubject = PassthroughSubject() private var checkStatusWorkItem: DispatchWorkItem? @@ -45,6 +69,7 @@ class NightscoutSettingsViewModel: ObservableObject { setupDebounce() checkNightscoutStatus() + observeWebSocketState() } private func setupDebounce() { @@ -125,6 +150,7 @@ class NightscoutSettingsViewModel: ObservableObject { case .emptyAddress: nightscoutStatus = "Address Empty" } + NightscoutSocketManager.shared.disconnect() } else { isConnected = true let authStatus: String @@ -141,4 +167,28 @@ class NightscoutSettingsViewModel: ObservableObject { } } } + + private func triggerRefresh() { + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + private func observeWebSocketState() { + updateWebSocketStatus() + NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateWebSocketStatus() + } + .store(in: &cancellables) + } + + private func updateWebSocketStatus() { + switch NightscoutSocketManager.shared.connectionState { + case .disconnected: webSocketStatus = "Disconnected" + case .connecting: webSocketStatus = "Connecting..." + case .connected: webSocketStatus = "Connected" + case .authenticated: webSocketStatus = "Connected" + case .error: webSocketStatus = "Error" + } + } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 7eb55888b..2d684c0ff 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -209,6 +209,7 @@ class Storage { var device = StorageValue(key: "device", defaultValue: "") var nsWriteAuth = StorageValue(key: "nsWriteAuth", defaultValue: false) var nsAdminAuth = StorageValue(key: "nsAdminAuth", defaultValue: false) + var webSocketEnabled = StorageValue(key: "webSocketEnabled", defaultValue: true) // When adding a new migration step in `runMigrationsIfNeeded()`, bump this default // to the new latest step number so fresh installs skip all migrations. Other defaults diff --git a/LoopFollow/Task/ProfileTask.swift b/LoopFollow/Task/ProfileTask.swift index 72de336fb..6b4358ca7 100644 --- a/LoopFollow/Task/ProfileTask.swift +++ b/LoopFollow/Task/ProfileTask.swift @@ -21,6 +21,10 @@ extension MainViewController { webLoadNSProfile() - TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(10 * 60)) + var interval: TimeInterval = 10 * 60 + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = 30 * 60 + } + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(interval)) } } diff --git a/LoopFollow/Task/TreatmentsTask.swift b/LoopFollow/Task/TreatmentsTask.swift index d3a0c620e..4e7aa0bc9 100644 --- a/LoopFollow/Task/TreatmentsTask.swift +++ b/LoopFollow/Task/TreatmentsTask.swift @@ -21,7 +21,11 @@ extension MainViewController { WebLoadNSTreatments() - TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(2 * 60)) + var interval: TimeInterval = 2 * 60 + if NightscoutSocketManager.shared.connectionState == .authenticated { + interval = 10 * 60 + } + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(interval)) TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index f8515ce7b..b16eac734 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -246,6 +246,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio showHideNSDetails() scheduleAllTasks() + setupNightscoutSocket() NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) @@ -589,6 +590,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio Observable.shared.minAgoText.value = "Refreshing" scheduleAllTasks() + NightscoutSocketManager.shared.connectIfNeeded() currentCage = nil currentSage = nil @@ -620,6 +622,8 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio if Storage.shared.backgroundRefreshType.value != .none { BackgroundAlertManager.shared.startBackgroundAlert() } + + NightscoutSocketManager.shared.disconnect() } // Migrations must only run when UserDefaults is accessible (i.e. after first unlock). @@ -725,6 +729,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } TaskScheduler.shared.checkTasksNow() + NightscoutSocketManager.shared.connectIfNeeded() checkAndNotifyVersionStatus() checkAppExpirationStatus() From 985e8b294b44d91f64dfaae419a1ca58631d6260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 22:20:14 +0200 Subject: [PATCH 08/22] Default debug logging on and prompt for description when sharing logs (#627) Flip the debugLogLevel default to true and add a migration step so existing users with it stored as false also get it enabled, ensuring shared logs contain useful detail when reporting problems. When the user taps Share Logs, present a sheet asking for a short description of the issue. The description is written to a ShareNotice_.txt file (date, app version, branch+sha, user description) and included alongside the log files in the iOS share sheet. --- LoopFollow.xcodeproj/project.pbxproj | 4 ++ LoopFollow/Settings/ShareLogNoticeView.swift | 37 ++++++++++++ LoopFollow/Storage/Storage+Migrate.swift | 7 +++ LoopFollow/Storage/Storage.swift | 4 +- .../ViewControllers/MainViewController.swift | 5 ++ LoopFollow/ViewControllers/MoreMenuView.swift | 57 ++++++++++++++++++- 6 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 LoopFollow/Settings/ShareLogNoticeView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 4fbee199b..0baf4be40 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ 6589CC6D2E9E7D1600BB18FE /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */; }; 6589CC6E2E9E7D1600BB18FE /* SettingsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */; }; 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; + 6589CC712E9E7D1600BB18FE /* ShareLogNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E7D1600BB18FE /* ShareLogNoticeView.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; 65A100012F5AA00000AA1001 /* UnitsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */; }; @@ -527,6 +528,7 @@ 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; + 6589CC702E9E7D1600BB18FE /* ShareLogNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogNoticeView.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; 65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsSettingsView.swift; sourceTree = ""; }; @@ -1002,6 +1004,7 @@ 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */, 657F98172F043D8100F732BD /* HomeContentView.swift */, 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */, + 6589CC702E9E7D1600BB18FE /* ShareLogNoticeView.swift */, 65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */, 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */, 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */, @@ -2329,6 +2332,7 @@ 65A100032F5AA00000AA1002 /* UnitsConfigurationView.swift in Sources */, 657F98182F043D8100F732BD /* HomeContentView.swift in Sources */, 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */, + 6589CC712E9E7D1600BB18FE /* ShareLogNoticeView.swift in Sources */, DD493ADF2ACF22BB009A6922 /* SAge.swift in Sources */, DDC6CA3F2DD7C6340060EE25 /* TemporaryAlarmEditor.swift in Sources */, DDF699992C5AA3060058A8D9 /* TempTargetPresetManager.swift in Sources */, diff --git a/LoopFollow/Settings/ShareLogNoticeView.swift b/LoopFollow/Settings/ShareLogNoticeView.swift new file mode 100644 index 000000000..5b6b3fe3b --- /dev/null +++ b/LoopFollow/Settings/ShareLogNoticeView.swift @@ -0,0 +1,37 @@ +// LoopFollow +// ShareLogNoticeView.swift + +import SwiftUI + +struct ShareLogNoticeView: View { + @State private var noticeText: String = "" + let onCancel: () -> Void + let onShare: (String) -> Void + + var body: some View { + NavigationView { + Form { + Section { + Text("Thanks for sharing these logs to help us find the problem. Please describe it in as much detail as possible — what time did it happen, what did you do, and what did you expect to happen that didn't?") + .font(.callout) + .foregroundColor(.secondary) + } + + Section(header: Text("Description")) { + TextEditor(text: $noticeText) + .frame(minHeight: 180) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationBarTitle("Share Logs", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + } + ToolbarItem(placement: .confirmationAction) { + Button("Share") { onShare(noticeText) } + } + } + } + } +} diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index d74fc0625..b2274675d 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -60,6 +60,13 @@ extension Storage { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs) } + func migrateStep9() { + // Default for debugLogLevel changed from false to true so users ship useful + // logs when they report a problem. Force-enable for existing users. + LogManager.shared.log(category: .general, message: "Running migrateStep9 — enabling debug log level") + debugLogLevel.value = true + } + func migrateStep6() { // APNs credential separation LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation") diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 2d684c0ff..98bc101ce 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -39,7 +39,7 @@ class Storage { var selectedBLEDevice = StorageValue(key: "selectedBLEDevice", defaultValue: nil) - var debugLogLevel = StorageValue(key: "debugLogLevel", defaultValue: false) + var debugLogLevel = StorageValue(key: "debugLogLevel", defaultValue: true) var contactTrend = StorageValue(key: "contactTrend", defaultValue: .off) var contactDelta = StorageValue(key: "contactDelta", defaultValue: .off) @@ -214,7 +214,7 @@ class Storage { // When adding a new migration step in `runMigrationsIfNeeded()`, bump this default // to the new latest step number so fresh installs skip all migrations. Other defaults // in this file must reflect the post-migration final state for a fresh install. - var migrationStep = StorageValue(key: "migrationStep", defaultValue: 7) + var migrationStep = StorageValue(key: "migrationStep", defaultValue: 9) var persistentNotification = StorageValue(key: "persistentNotification", defaultValue: false) var persistentNotificationLastBGTime = StorageValue(key: "persistentNotificationLastBGTime", defaultValue: .distantPast) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index b16eac734..9538f5559 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -692,6 +692,11 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio Storage.shared.migrateStep8() Storage.shared.migrationStep.value = 8 } + + if Storage.shared.migrationStep.value < 9 { + Storage.shared.migrateStep9() + Storage.shared.migrationStep.value = 9 + } } @objc func appDidBecomeActive() { diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 8bb0a1373..ac389a122 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -140,10 +140,65 @@ struct MoreMenuView: View { showAlert = true return } - let avc = UIActivityViewController(activityItems: files, applicationActivities: nil) + + 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() From 0841b1bd70119f641d08de8f85b8cdbd952bc38a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 22:34:56 +0200 Subject: [PATCH 09/22] Add optional yesterday BG comparison line to main graph (#665) Adds a Graph Settings toggle (Nightscout only) that overlays yesterday's BG curve on the main graph, time-shifted by 24h so it aligns with the same clock time today. Drawn as a thin dimmed gray line with no dots, purely for comparison. When enabled, one extra day of history is fetched and the overlay is capped to now plus the configured hours of prediction so it never extends further into the future than the prediction line. --- LoopFollow/Controllers/Graphs.swift | 45 +++++++++++++++++++ .../Controllers/Nightscout/BGData.swift | 35 +++++++++++++-- LoopFollow/Settings/GraphSettingsView.swift | 4 ++ LoopFollow/Storage/Storage.swift | 1 + .../ViewControllers/MainViewController.swift | 1 + 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index da0259ff1..8701be7cf 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -43,6 +43,7 @@ enum GraphDataIndex: Int { case smb = 16 case tempTarget = 17 case predictionCone = 18 + case yesterday = 19 } extension GraphDataIndex { @@ -67,6 +68,7 @@ extension GraphDataIndex { case .smb: return "SMB" case .tempTarget: return "Temp Target" case .predictionCone: return "Prediction Cone" + case .yesterday: return "Yesterday" } } } @@ -638,6 +640,16 @@ extension MainViewController { lineCone.axisDependency = YAxis.AxisDependency.right data.append(lineCone) + // Dataset 19: Yesterday's BG comparison overlay (thin dimmed gray line, no dots) + let lineYesterday = LineChartDataSet(entries: [ChartDataEntry](), label: "") + lineYesterday.lineWidth = 1.5 + lineYesterday.setColor(NSUIColor.systemGray, alpha: 0.4) + lineYesterday.drawCirclesEnabled = false + lineYesterday.drawValuesEnabled = false + lineYesterday.highlightEnabled = false + lineYesterday.axisDependency = YAxis.AxisDependency.right + data.append(lineYesterday) + data.setValueFont(UIFont.systemFont(ofSize: 12)) // Add marker popups for bolus and carbs @@ -829,6 +841,11 @@ extension MainViewController { BGChart.data?.notifyDataChanged() BGChart.notifyDataSetChanged() + // Reflect the yesterday overlay toggle immediately, and reload the BG window + // so the extra day of history is fetched (or dropped) when the toggle changed. + updateYesterdayBGGraph() + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + // Re-render prediction display in case display type changed updateOpenAPSPredictionDisplay() } @@ -898,6 +915,8 @@ extension MainViewController { BGChartFull.data?.notifyDataChanged() BGChartFull.notifyDataSetChanged() + updateYesterdayBGGraph() + // The initial zoom is a one-shot, relative to the chart's current // viewport. Skip it until the chart actually has a width — otherwise a // refresh that lands while the view is loaded but off-screen (e.g. Home @@ -922,6 +941,32 @@ extension MainViewController { } } + // Populates (or clears) the dimmed "yesterday" comparison overlay on the main graph. + // Points in yesterdayBGData are already shifted +24h so they align with today's clock time. + func updateYesterdayBGGraph() { + let dataIndex = GraphDataIndex.yesterday.rawValue + guard let lineData = BGChart.lineData, + dataIndex < lineData.dataSets.count, + let dataSet = lineData.dataSets[dataIndex] as? LineChartDataSet + else { + return + } + + dataSet.removeAll(keepingCapacity: false) + + if Storage.shared.showYesterdayLine.value { + for entry in yesterdayBGData { + // Clamp the plotted y-value to the same bounds the main BG line uses. + let plottedSgv = Double(min(max(entry.sgv, globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)) + dataSet.append(ChartDataEntry(x: entry.date, y: plottedSgv)) + } + } + + BGChart.data?.dataSets[dataIndex].notifyDataSetChanged() + BGChart.data?.notifyDataChanged() + BGChart.notifyDataSetChanged() + } + func updatePredictionGraph(color: UIColor? = nil) { let dataIndex = 1 var mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index dbdd27afc..2d6c73a77 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -5,11 +5,19 @@ import Foundation import UIKit extension MainViewController { + /// Number of days of BG history to request from the source. One extra day is + /// added when the "Show Yesterday's BG" overlay is enabled (Nightscout only), + /// so the overlay can display the same clock time from the day before. + var bgFetchDays: Int { + let extraDay = (Storage.shared.showYesterdayLine.value && IsNightscoutEnabled()) ? 1 : 0 + return Storage.shared.downloadDays.value + extraDay + } + // Dex Share Web Call func webLoadDexShare() { // Dexcom Share only returns 24 hrs of data as of now // Requesting more just for consistency with NS - let graphHours = 24 * Storage.shared.downloadDays.value + let graphHours = 24 * bgFetchDays let count = graphHours * 12 dexShare?.fetchData(count) { err, result in if let error = err { @@ -57,8 +65,8 @@ extension MainViewController { } var parameters: [String: String] = [:] - let date = Calendar.current.date(byAdding: .day, value: -1 * Storage.shared.downloadDays.value, to: Date())! - parameters["count"] = "\(Storage.shared.downloadDays.value * globalVariables.maxExpectedUploaders * 24 * 60 / 5)" + let date = Calendar.current.date(byAdding: .day, value: -1 * bgFetchDays, to: Date())! + parameters["count"] = "\(bgFetchDays * globalVariables.maxExpectedUploaders * 24 * 60 / 5)" parameters["find[date][$gte]"] = "\(Int(date.timeIntervalSince1970 * 1000))" // Exclude 'cal' entries @@ -221,6 +229,27 @@ extension MainViewController { LogManager.shared.log(category: .nightscout, message: "Graph data updated with \(bgData.count) entries.", isDebug: true) + + // Build the optional "yesterday" comparison overlay. Every fetched reading is + // shifted +24h so it lines up with the same clock time today; the extra day of + // history pulled by bgFetchDays provides the portion that falls inside the + // visible window. The overlay is capped to "now + hours of prediction" so it + // never extends further into the future than the prediction line. + yesterdayBGData.removeAll() + if Storage.shared.showYesterdayLine.value, IsNightscoutEnabled() { + let cutoff = dateTimeUtils.getTimeIntervalNHoursAgo(N: 24 * bgFetchDays) + let futureLimit = dateTimeUtils.getNowTimeIntervalUTC() + Storage.shared.predictionToLoad.value * 3600 + for i in 0 ..< data.count { + let reading = data[data.count - 1 - i] + guard reading.date >= cutoff, reading.sgv <= 600 else { continue } + let shiftedDate = reading.date + 24 * 60 * 60 + guard shiftedDate <= futureLimit else { continue } + yesterdayBGData.append(ShareGlucoseData(sgv: reading.sgv, + date: shiftedDate, + direction: reading.direction)) + } + } + viewUpdateNSBG(sourceName: sourceName) } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index cd0fd40a9..d1de43c97 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -12,6 +12,7 @@ struct GraphSettingsView: View { @ObservedObject private var show30MinLine = Storage.shared.show30MinLine @ObservedObject private var show90MinLine = Storage.shared.show90MinLine @ObservedObject private var showMidnightLines = Storage.shared.showMidnightLines + @ObservedObject private var showYesterdayLine = Storage.shared.showYesterdayLine @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight @@ -42,6 +43,9 @@ struct GraphSettingsView: View { Toggle("Show −90 min Line", isOn: $show90MinLine.value) .onChange(of: show90MinLine.value) { _ in markDirty() } + + Toggle("Show Yesterday's BG", isOn: $showYesterdayLine.value) + .onChange(of: showYesterdayLine.value) { _ in markDirty() } } Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 98bc101ce..4dd7370a8 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -129,6 +129,7 @@ class Storage { var show30MinLine = StorageValue(key: "show30MinLine", defaultValue: false) var show90MinLine = StorageValue(key: "show90MinLine", defaultValue: false) var showMidnightLines = StorageValue(key: "showMidnightMarkers", defaultValue: false) + var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) var smallGraphHeight = StorageValue(key: "smallGraphHeight", defaultValue: 40) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9538f5559..c93f72f89 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -73,6 +73,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio var profileManager = ProfileManager.shared var bgData: [ShareGlucoseData] = [] + var yesterdayBGData: [ShareGlucoseData] = [] // readings already shifted +24h for the comparison overlay var basalProfile: [basalProfileStruct] = [] var basalData: [basalGraphStruct] = [] var basalScheduleData: [basalGraphStruct] = [] From 25650a7f0ba7982fb6282e7ff026a28fe3e43f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 15 Jun 2026 21:38:22 +0200 Subject: [PATCH 10/22] Scale info data table text with Dynamic Type (#667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove Main.storyboard and migrate to SwiftUI app lifecycle Replace UIKit storyboard/SceneDelegate architecture with SwiftUI App entry point (LoopFollowApp.swift) and TabView (MainTabView.swift). Convert MoreMenuViewController to SwiftUI (MoreMenuView.swift). Add SwiftUI wrappers for Remote and Nightscout tabs. Remove 6 obsolete UIKit wrapper view controllers and ~300 lines of tab management code from MainViewController. * Migrate info table from UITableView to SwiftUI List Replace UITableView with SwiftUI InfoTableView hosted in MainViewController. Make InfoManager an ObservableObject so data updates trigger SwiftUI rebuilds automatically. Remove UITableViewDataSource conformance and table delegate methods. No changes needed to the 10 Nightscout controller files that populate the table data. * Migrate statistics and pie chart from UIKit to SwiftUI Replace 7 UILabel properties and DGCharts PieChartView with a StatsDisplayModel ObservableObject and hosted StatsDisplayView. The pie chart uses a UIViewRepresentable wrapper for DGCharts since the Charts pod name shadows Swift Charts. Remove ~60 lines of UIKit stack layout code from MainViewController setupUI(). * Migrate BG display area from UIKit labels to SwiftUI Replace BGText, DirectionText, DeltaText, MinAgoText, serverText, LoopStatusLabel, and PredictionLabel with a SwiftUI BGDisplayView. Add pull-to-refresh via .refreshable modifier. Move loop status and prediction text updates to Observable values across DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, and BGData. Remove UIScrollView overlay and UIScrollViewDelegate conformance. * Migrate main layout to SwiftUI with UIKit charts embedded Replace UIStackView layout with MainHomeView SwiftUI view that composes BGDisplayView, InfoTableView, LineChartWrapper (UIViewRepresentable for DGCharts), and StatsDisplayView. MainViewController now hosts a single UIHostingController instead of managing individual UIView containers. Visibility of info table, small graph, and stats is now reactive via Storage observables in SwiftUI, removing several Combine subscriptions. BG text uses lineLimit + minimumScaleFactor instead of manual font sizing. * Clean up migration artifacts and fix post-migration bugs - Fix AVSpeechSynthesizer temporary in AppDelegate that would be deallocated before speech completes; use stored property instead - Fix appMovedToBackground tab switching to use Observable instead of dead UIKit tabBarController reference - Remove dead code: rebuildTabsIfNeeded(), updateNightscoutTabState(), traitCollectionDidChange notification relay, UIViewExtension.addBorder - Remove unused imports (Charts, UIKit, Combine) from migrated files - Remove unused synthesizer from LoopFollowApp - Remove redundant .appearanceDidChange subscription from NightscoutVC - Add missing super calls in viewWillAppear/viewDidAppear * Replace view hierarchy walking with MainViewController.shared The getMainViewController() methods in TreatmentsView, SettingsMenuView, and BackgroundRefreshManager tried to find MainViewController by casting rootViewController as UITabBarController, which always fails with the SwiftUI lifecycle. Add a weak static shared reference set during viewDidLoad and use it everywhere instead. * Fix MainViewController.shared references for stats and treatments Pass MainViewController.shared instead of nil when creating AggregatedStatsContentView in MainTabView and MoreMenuView. Replace view-hierarchy-walking getMainViewController() in TreatmentsViewModel with MainViewController.shared. * Fix info table font size to match storyboard The storyboard used system 17pt for both title and detail labels. The SwiftUI migration used .subheadline (~15pt) making text smaller. * Fix Share Logs sheet rendering blank Present UIActivityViewController via UIApplication.topMost instead of wrapping it in a SwiftUI .sheet, which rendered an empty view. * Fix back navigation from Settings sub-pages SettingsMenuView declared its own NavigationStack(path:) while already being pushed onto the outer NavigationStack from MainTabView, so sub-page back buttons popped the outer stack and jumped past Settings to Menu. Drop the nested NavigationStack and route Settings entries through the ambient stack: a single SettingsRoute enum drives a navigationDestination attached at the MoreMenuView root. The Settings entry itself becomes a NavigationLink(value:) so it doesn't compete with a navigationDestination (isPresented:) modifier, which was re-asserting Settings as the top of stack whenever a sub-page was pushed. * Harden post-storyboard migration * MainViewController is now a strong static singleton bootstrapped from LoopFollowApp.init(). Lifecycle work in viewDidLoad (Combine sinks, observers, scheduleAllTasks, migrations) runs at launch regardless of whether the Home tab is rendered, and HomeContentView reuses the singleton instead of instantiating a fresh VC each time. * MoreMenuView's eight .navigationDestination(isPresented:) modifiers are collapsed to a single MenuRoute enum routed through one .navigationDestination(for:), preventing the same destination-slot contention that previously caused Settings → Graph back navigation to jump past Settings. * MainTabView observes Storage.shared.appearanceMode so theme changes propagate; the orphaned .appearanceDidChange notification name is removed. * OPEN_APP_ACTION notification taps now dismiss any presented modal before switching to Home, matching prior SceneDelegate behavior. * Drop the unused Core Data stack (NSPersistentCloudKitContainer, saveContext) from AppDelegate, the dead AppDelegate.window property, and the legacy UIRequiredDeviceCapabilities=armv7 / UIStatusBarTintParameters keys from Info.plist. Switch AlarmSound's keyWindow access to the connected-scenes API and generalize UIApplication.topMost likewise so it works on Mac Catalyst. * Strip redundant inner NavigationView wrappers from settings sub-views pushed onto the outer NavigationStack: Graph, General, Advanced, Calendar, Contact, Dexcom, Nightscout, BackgroundRefresh, InfoDisplay, ImportExport. Drop unused onBack parameters from AlarmsContainerView and SettingsMenuView, the unused isPresentedAsModal flag from MainViewController, and the leftover debug print in ObservableValue.set. * LineChartWrapper.updateUIView now flushes the chart on SwiftUI re-render. MainViewController.deinit removes all observers, not just the custom "refresh" one. MoreMenuView caches the app version in @State instead of constructing AppVersionManager on every body re-render. HomeModalView uses NavigationStack (not deprecated NavigationView). * MoreMenuView: render tab-switch buttons in primary color Buttons in a List inherit the accent tint, so the Features rows that switch tabs appeared blue while the NavigationLink rows that push appeared white. Use .buttonStyle(.plain) to suppress the tint and drop the now-redundant .foregroundStyle(.primary) calls. * Revert MainViewController singleton bootstrap Constructing MainViewController.shared from LoopFollowApp.init() — and reusing the same VC across HomeContentView re-creations — caused tapping the BG chart to crash with `-[__NSArrayM insertObject:atIndex:]: object cannot be nil`. Bisected to the singleton+bootstrap piece of the post-storyboard hardening; the rest of that commit (programmatic UI, MoreMenuView routing, NavigationView strip-out) is retained. Restore the prior behavior: shared is a weak static set in viewDidLoad, HomeContentView constructs a fresh MainViewController each time, and the LoopFollowApp.init() bootstrap is removed. Known follow-up: lifecycle work in viewDidLoad (Combine sinks, scheduleAllTasks, migrations) again only runs when the Home view is first rendered, so a user who has moved Home off the tab bar gets degraded behavior until they navigate to it. * MoreMenuView: make tab-switch rows full-row tappable Wrap Button labels in an HStack with a trailing Spacer and contentShape so the entire row is tappable, matching the hit area of NavigationLink rows. Extract the pattern into a small FullRowButton helper, used for both tab-switch rows and Share Logs. * Align units-selection conflict resolution with integration branch Move the Diagnostics section out of Section("Speak BG") (was nested at the wrong indent), match StatsDisplayModel field order, and add the spacing line in updateStats. * MoreMenuView: fix cross-row tap routing in Features section Mixing Button and NavigationLink rows in the same List ForEach caused taps on a NavigationLink row to fire a sibling Button row's action — e.g. tapping Alarms with Stats in the tab bar would switch to the Stats tab instead of pushing the alarms detail. Make every row in the menu's List a uniform FullRowButton and drive pushes from state via .navigationDestination(isPresented:). Add an opt-in chevron to FullRowButton so navigating rows render the standard disclosure indicator. * MoreMenuView: keep Settings as a value-based NavigationLink Mixing .navigationDestination(isPresented:) with .navigationDestination(for:) on the same view shadowed the value-based SettingsRoute registration once SettingsMenuView was on the stack, so sub-rows like Units and Metrics couldn't push. Settings sits alone in its section, so it doesn't need the uniform-Button treatment used in Features and Logging — restore it to a NavigationLink and route it through the existing .navigationDestination(for:) channel. * Fix navigation between alarms and menu * Drive Before-First-Unlock recovery from AppDelegate Move BFU recovery (Storage.reloadAll) out of MainViewController and into AppDelegate so it runs even when the home tab's UIHostingController has not yet materialized — necessary under the SwiftUI App lifecycle, where a BG-only launch (BGAppRefreshTask, BLE wake, prewarming) may complete and the device may unlock without MainViewController ever being created. AppDelegate observes protectedDataDidBecomeAvailable (authoritative signal) and willEnterForeground (fallback), with a race-guard re-check immediately after observer registration. Recovery is idempotent via needsBFUReload. MainViewController now reacts to a new .bfuReloadCompleted notification by showing the loading overlay and rescheduling tasks; if it is not alive when the notification fires, its viewDidLoad will later see the already-reloaded Storage values and schedule tasks correctly on first load. * Keep MainViewController alive regardless of tab layout Make MainViewController.shared a strong, long-lived singleton created once via bootstrap() on first foreground, so the data pipeline, alarms and background audio run even when Home is moved into the Menu rather than a tab. Home views reuse the single instance instead of creating new ones, so the singleton is never displaced. Defer the one-shot BG graph zoom until the chart has a real frame and re-render the graph on every appearance, so the curve stays visible when Home is reached from the Menu or moved between tab bar and Menu. Restore the one-time telemetry consent prompt that was lost when SceneDelegate was removed, presenting it from MainTabView on first appearance for undecided installs. * Scale info data table text with Dynamic Type Drive the info table font size and row height from @ScaledMetric so the top-right info data grows and shrinks with the iPhone's text-size setting, keeping the existing 17pt/21pt look at the default size. Cap the scaling at accessibility1 so the compact top strip stays within its layout. * Use two rows if needed * Info table adjustments --- .../Nightscout/DeviceStatusOpenAPS.swift | 2 +- LoopFollow/InfoTable/InfoTableView.swift | 36 ++++++++++++++----- LoopFollow/ViewControllers/MainHomeView.swift | 1 + 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 4d4105fbb..ca8338a47 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -201,7 +201,7 @@ extension MainViewController { Storage.shared.lastMinBgMgdl.value = minPredBG Storage.shared.lastMaxBgMgdl.value = maxPredBG } else { - infoManager.updateInfoData(type: .minMax, value: "N/A") + infoManager.clearInfoData(type: .minMax) } updateOpenAPSPredictionDisplay() diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift index 218b3fe70..948cd94d2 100644 --- a/LoopFollow/InfoTable/InfoTableView.swift +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -7,6 +7,9 @@ struct InfoTableView: View { @ObservedObject var infoManager: InfoManager var timeZoneOverride: String? + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = 17 + @ScaledMetric(relativeTo: .body) private var rowHeight: CGFloat = 21 + var body: some View { List { if let tz = timeZoneOverride { @@ -17,18 +20,35 @@ struct InfoTableView: View { } } .listStyle(.plain) - .environment(\.defaultMinListRowHeight, 21) + .environment(\.defaultMinListRowHeight, rowHeight) } private func row(name: String, value: String) -> some View { - HStack { - Text(name) - Spacer() - Text(value) - .foregroundStyle(.primary) + // Show a placeholder for any field that has no value yet, + // so the row reads as "no data" rather than appearing empty. + let displayValue = value.isEmpty ? "—" : value + + return ViewThatFits(in: .horizontal) { + // Preferred: compact single line (label — value) + HStack { + Text(name) + Spacer() + Text(displayValue) + .foregroundStyle(.primary) + } + + // Fallback when the single line won't fit: label over value + VStack(alignment: .leading, spacing: 0) { + Text(name) + Text(displayValue) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .trailing) + } } - .font(.system(size: 17)) - .frame(height: 21) + .font(.system(size: fontSize)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .frame(minHeight: rowHeight) .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) } } diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift index 6e933bbb8..e8509d94d 100644 --- a/LoopFollow/ViewControllers/MainHomeView.swift +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -40,6 +40,7 @@ struct MainHomeView: View { if isNightscoutEnabled && !hideInfoTable.value { InfoTableView(infoManager: infoManager, timeZoneOverride: timeZoneOverride) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) .frame(minWidth: 160, maxWidth: 250) .overlay( Rectangle() From 33d6eb965d57b247e8e56ba9d5b3b089a06e7be9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 19:38:33 +0000 Subject: [PATCH 11/22] CI: Bump dev version to 6.1.7 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index ad50ecd73..18705b724 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.6 +LOOP_FOLLOW_MARKETING_VERSION = 6.1.7 From d03e4388a111e323af0084fe8acf051fff4f794a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=BDubor=20Jurena?= <60156551+luborjurena@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:06:23 +0200 Subject: [PATCH 12/22] ITMS-91061 - privacy manifest for Charts framework (#666) --- Podfile | 32 +++++++++++++++++++++++++++++ Pods/Charts/PrivacyInfo.xcprivacy | 14 +++++++++++++ Pods/Pods.xcodeproj/project.pbxproj | 4 ++++ 3 files changed, 50 insertions(+) create mode 100644 Pods/Charts/PrivacyInfo.xcprivacy diff --git a/Podfile b/Podfile index f8c2df3b2..1229b37d6 100644 --- a/Podfile +++ b/Podfile @@ -33,4 +33,36 @@ post_install do |installer| File.write(transformer, code.sub(original, patched)) end end + + # Inject a privacy manifest into the Charts framework (ITMS-91061). + # Charts 4.1.0 ships no PrivacyInfo.xcprivacy; it collects no data, performs + # no tracking, and uses no required-reason APIs, so this is a negative + # declaration. Re-applied here because `pod install` regenerates the project. + charts_manifest = <<~XML + + + + + \tNSPrivacyTracking + \t + \tNSPrivacyTrackingDomains + \t + \tNSPrivacyCollectedDataTypes + \t + \tNSPrivacyAccessedAPITypes + \t + + + XML + + manifest_path = installer.sandbox.root + 'Charts/PrivacyInfo.xcprivacy' + File.write(manifest_path, charts_manifest) + + charts_target = installer.pods_project.targets.find { |t| t.name == 'Charts' } + if charts_target + file_ref = installer.pods_project.new_file(manifest_path.to_s) + already_added = charts_target.resources_build_phase.files_references.include?(file_ref) + charts_target.resources_build_phase.add_file_reference(file_ref) unless already_added + installer.pods_project.save + end end diff --git a/Pods/Charts/PrivacyInfo.xcprivacy b/Pods/Charts/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..e08a130bc --- /dev/null +++ b/Pods/Charts/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/Pods/Pods.xcodeproj/project.pbxproj b/Pods/Pods.xcodeproj/project.pbxproj index 6a2b71889..489d852f5 100644 --- a/Pods/Pods.xcodeproj/project.pbxproj +++ b/Pods/Pods.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + BEEF11110000000000000002 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BEEF11110000000000000001 /* PrivacyInfo.xcprivacy */; }; 0031CEAD857A1BA158C0D2BE1870A438 /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB10D8BF2F0C2E9B18C2130751D75984 /* Animator.swift */; }; 00351B69906B07C1950B97C40F65FABE /* RadarChartData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51F17D0D2858BA593E783811A2CF7E6 /* RadarChartData.swift */; }; 00E171A24B8B214FDC447BED6BB62C31 /* BarLineScatterCandleBubbleChartDataSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90B940BBEE17272D0FA9602CB3F1E1 /* BarLineScatterCandleBubbleChartDataSet.swift */; }; @@ -220,6 +221,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + BEEF11110000000000000001 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 0047677618FCD329E548FDDC0454A8AC /* LineChartView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LineChartView.swift; path = Source/Charts/Charts/LineChartView.swift; sourceTree = ""; }; 0055C3AB0AE0067E0AF50DA569843398 /* BarChartView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BarChartView.swift; path = Source/Charts/Charts/BarChartView.swift; sourceTree = ""; }; 0187F74F0F2E1ADE4F205C203F88DE85 /* RadarChartView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RadarChartView.swift; path = Source/Charts/Charts/RadarChartView.swift; sourceTree = ""; }; @@ -759,6 +761,7 @@ E5EBEB4E3DD769CF77FBA0FB4DD5A204 /* Charts */ = { isa = PBXGroup; children = ( + BEEF11110000000000000001 /* PrivacyInfo.xcprivacy */, 64FE9452040001E25B5A3EB8D58108DB /* Core */, AEDEF3B82F3D135001FB2D1778DAD53F /* Support Files */, ); @@ -944,6 +947,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + BEEF11110000000000000002 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 4f67724126f91f74974ff7b0e10c031c8f1f936b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 00:06:33 +0000 Subject: [PATCH 13/22] CI: Bump dev version to 6.1.8 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 18705b724..e6b561186 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.7 +LOOP_FOLLOW_MARKETING_VERSION = 6.1.8 From 2c0cedacffb4042800fb1762c64b960b87210abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 16 Jun 2026 05:16:04 +0200 Subject: [PATCH 14/22] Remove the Nightscout-based remote command path (#618) Trio dev's APNS-based remote command support has merged to main, so the Nightscout treatment-posting variant of remote commands is no longer needed. Drop the .nightscout case from RemoteType, the Nightscout remote view and controller, the unused NoRemoteView fallback, and all .nightscout switch arms in the remote settings, import/export, and device validation paths. Storage keys are reused by the TRC and LoopAPNS variants and remain untouched. Existing users with remoteType set to "Nightscout" automatically fall back to .none on next launch: StorageValue's JSONDecoder fails on the removed enum case and returns the default value. --- LoopFollow.xcodeproj/project.pbxproj | 12 - .../Nightscout/TrioNightscoutRemoteView.swift | 303 ------------------ LoopFollow/Remote/NoRemoteView.swift | 18 -- LoopFollow/Remote/RemoteContentView.swift | 20 -- LoopFollow/Remote/RemoteType.swift | 1 - .../Settings/RemoteCommandSettings.swift | 6 - .../Remote/Settings/RemoteSettingsView.swift | 12 +- .../Settings/RemoteSettingsViewModel.swift | 2 +- .../TRC/TrioNightscoutRemoteController.swift | 51 --- .../ImportExport/ExportableSettings.swift | 6 - .../ViewControllers/MainViewController.swift | 3 +- 11 files changed, 3 insertions(+), 431 deletions(-) delete mode 100644 LoopFollow/Remote/Nightscout/TrioNightscoutRemoteView.swift delete mode 100644 LoopFollow/Remote/NoRemoteView.swift delete mode 100644 LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 0baf4be40..b2f706d3f 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -94,7 +94,6 @@ DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6A2C48562000DBADDF /* InsulinMetric.swift */; }; DD0C0C6D2C48606200DBADDF /* CarbMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C6C2C48606200DBADDF /* CarbMetric.swift */; }; DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */; }; - DD0C0C722C4B000800DBADDF /* TrioNightscoutRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */; }; AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */; }; BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */; }; DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */; }; @@ -127,7 +126,6 @@ DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878122C7B750D0048F05C /* TempTargetView.swift */; }; DD4878152C7B75230048F05C /* MealView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878142C7B75230048F05C /* MealView.swift */; }; DD4878172C7B75350048F05C /* BolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878162C7B75350048F05C /* BolusView.swift */; }; - DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */; }; DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */; }; DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781F2C7DAF890048F05C /* PushMessage.swift */; }; DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AD42ACF2109009A6922 /* ResumePump.swift */; }; @@ -264,7 +262,6 @@ A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; - DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE69ED12C7256260013EAEC /* RemoteType.swift */; }; DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE75D222DE5E505007C1FC1 /* Glyph.swift */; }; DDE75D272DE5E539007C1FC1 /* ActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE75D262DE5E539007C1FC1 /* ActionRow.swift */; }; @@ -562,7 +559,6 @@ DD0C0C6A2C48562000DBADDF /* InsulinMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinMetric.swift; sourceTree = ""; }; DD0C0C6C2C48606200DBADDF /* CarbMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbMetric.swift; sourceTree = ""; }; DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContentView.swift; sourceTree = ""; }; - DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteView.swift; sourceTree = ""; }; AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopFollowApp.swift; sourceTree = ""; }; BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmsContainerView.swift; sourceTree = ""; }; @@ -595,7 +591,6 @@ DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = ""; }; DD4878142C7B75230048F05C /* MealView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealView.swift; sourceTree = ""; }; DD4878162C7B75350048F05C /* BolusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusView.swift; sourceTree = ""; }; - DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteController.swift; sourceTree = ""; }; DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationManager.swift; sourceTree = ""; }; DD48781F2C7DAF890048F05C /* PushMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessage.swift; sourceTree = ""; }; DD493AD42ACF2109009A6922 /* ResumePump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumePump.swift; sourceTree = ""; }; @@ -736,7 +731,6 @@ A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; - DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; DDE69ED12C7256260013EAEC /* RemoteType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteType.swift; sourceTree = ""; }; DDE75D222DE5E505007C1FC1 /* Glyph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Glyph.swift; sourceTree = ""; }; DDE75D262DE5E539007C1FC1 /* ActionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRow.swift; sourceTree = ""; }; @@ -1099,7 +1093,6 @@ B500000000000000000000A5 /* QuickPickBoluses */, B500000000000000000000B5 /* QuickPickMeals */, DDDF6F4A2D479B6A00884336 /* Nightscout */, - DDDF6F482D479AEF00884336 /* NoRemoteView.swift */, DDEF503E2D479B8A00884336 /* LoopAPNS */, DD4878112C7B74F90048F05C /* TRC */, DD4878062C7B2E9E0048F05C /* Settings */, @@ -1155,7 +1148,6 @@ DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */, DD4878162C7B75350048F05C /* BolusView.swift */, DDD10F022C518A6500D76A8E /* TreatmentResponse.swift */, - DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */, DDFD5C522CB167DA00D3FD68 /* TRCCommandType.swift */, DDBE3ABC2CB5A961006B37DC /* OverrideView.swift */, ); @@ -1399,7 +1391,6 @@ DDDF6F4A2D479B6A00884336 /* Nightscout */ = { isa = PBXGroup; children = ( - DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */, ); path = Nightscout; sourceTree = ""; @@ -2205,7 +2196,6 @@ DD7F4C072DD5042F00D449E9 /* OverrideStartAlarmEditor.swift in Sources */, DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */, DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */, - DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */, 656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */, DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */, DD83164A2DE4C504004467AA /* SettingsStepperRow.swift in Sources */, @@ -2337,7 +2327,6 @@ DDC6CA3F2DD7C6340060EE25 /* TemporaryAlarmEditor.swift in Sources */, DDF699992C5AA3060058A8D9 /* TempTargetPresetManager.swift in Sources */, DDC6CA452DD8D8E60060EE25 /* PumpChangeCondition.swift in Sources */, - DD0C0C722C4B000800DBADDF /* TrioNightscoutRemoteView.swift in Sources */, DD493ADB2ACF21A3009A6922 /* Bolus.swift in Sources */, DDF9676E2AD08C6E00C5EB95 /* SiteChange.swift in Sources */, DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */, @@ -2417,7 +2406,6 @@ DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */, DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */, DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */, - DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */, FC1BDD2B24A22650001B652C /* Stats.swift in Sources */, DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */, DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */, diff --git a/LoopFollow/Remote/Nightscout/TrioNightscoutRemoteView.swift b/LoopFollow/Remote/Nightscout/TrioNightscoutRemoteView.swift deleted file mode 100644 index b0e1dfe01..000000000 --- a/LoopFollow/Remote/Nightscout/TrioNightscoutRemoteView.swift +++ /dev/null @@ -1,303 +0,0 @@ -// LoopFollow -// TrioNightscoutRemoteView.swift - -import HealthKit -import SwiftUI - -struct TrioNightscoutRemoteView: View { - private let remoteController = TrioNightscoutRemoteController() - - @ObservedObject var nightscoutURL = Storage.shared.url - @ObservedObject var device = Storage.shared.device - @ObservedObject var nsWriteAuth = Storage.shared.nsWriteAuth - @ObservedObject var tempTarget = Observable.shared.tempTarget - - @State private var newHKTarget = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0.0) - @State private var duration = HKQuantity(unit: .minute(), doubleValue: 0.0) - @State private var showAlert: Bool = false - @State private var alertType: AlertType? = nil - @State private var alertMessage: String? = nil - @State private var isLoading: Bool = false - @State private var statusMessage: String? = nil - - @State private var showPresetSheet: Bool = false - @State private var presetName = "" - @ObservedObject var presetManager = TempTargetPresetManager.shared - - @FocusState private var targetFieldIsFocused: Bool - @FocusState private var durationFieldIsFocused: Bool - - enum AlertType { - case confirmCommand - case status - case validation - case confirmCancellation - } - - var body: some View { - NavigationView { - VStack { - if nightscoutURL.value.isEmpty { - ErrorMessageView( - message: "Remote commands are currently only available for Trio. It requires you to enter your Nightscout address and a token with the careportal role in the settings." - ) - } else if device.value != "Trio" { - ErrorMessageView( - message: "Remote commands are currently only available for Trio." - ) - } else if !nsWriteAuth.value { - ErrorMessageView( - message: "Please update your token to include the 'careportal' and 'readable' roles in order to do remote commands with Trio." - ) - } else { - Form { - if let tempTargetValue = tempTarget.value { - Section(header: Text("Existing Temp Target")) { - HStack { - Text("Current Target") - Spacer() - Text(Localizer.formatQuantity(tempTargetValue)) - Text(Localizer.getPreferredUnit().localizedShortUnitString).foregroundColor(.secondary) - } - Button { - alertType = .confirmCancellation - showAlert = true - } label: { - HStack { - Text("Cancel Temp Target") - Spacer() - Image(systemName: "xmark.app") - .font(.title) - } - } - .tint(.red) - } - } - Section(header: Text("Temporary Target")) { - HStack { - Text("Target") - Spacer() - TextFieldWithToolBar( - quantity: $newHKTarget, - maxLength: 4, - unit: Localizer.getPreferredUnit(), - minValue: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80), - maxValue: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200), - onValidationError: { message in - handleValidationError(message) - } - ) - .focused($targetFieldIsFocused) - Text(Localizer.getPreferredUnit().localizedShortUnitString).foregroundColor(.secondary) - } - HStack { - Text("Duration") - Spacer() - TextFieldWithToolBar( - quantity: $duration, - maxLength: 4, - unit: HKUnit.minute(), - minValue: HKQuantity(unit: .minute(), doubleValue: 5), - onValidationError: { message in - handleValidationError(message) - } - ) - .focused($durationFieldIsFocused) - Text("minutes").foregroundColor(.secondary) - } - HStack { - Button { - alertType = .confirmCommand - showAlert = true - targetFieldIsFocused = false - durationFieldIsFocused = false - } label: { - Text("Enact") - } - .disabled(isButtonDisabled) - .buttonStyle(BorderlessButtonStyle()) - .font(.callout) - .controlSize(.mini) - - Spacer() - - Button { - showPresetSheet = true - targetFieldIsFocused = false - durationFieldIsFocused = false - } label: { - Text("Save as Preset") - } - .disabled(isButtonDisabled) - .buttonStyle(BorderlessButtonStyle()) - .font(.callout) - .controlSize(.mini) - } - } - - if !presetManager.presets.isEmpty { - Section(header: Text("Presets")) { - ForEach(presetManager.presets) { preset in - HStack { - Text(preset.name) - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - alertType = .confirmCommand - newHKTarget = preset.target - duration = preset.duration - showAlert = true - targetFieldIsFocused = false - durationFieldIsFocused = false - } - .swipeActions { - Button(role: .destructive) { - if let index = presetManager.presets.firstIndex(where: { $0.id == preset.id }) { - presetManager.deletePreset(at: index) - } - targetFieldIsFocused = false - durationFieldIsFocused = false - } label: { - Label("Delete", systemImage: "trash") - } - } - } - } - } - } - - if isLoading { - ProgressView("Please wait...") - .padding() - } - } - } - .navigationTitle("Remote") - .navigationBarTitleDisplayMode(.inline) - .alert(isPresented: $showAlert) { - switch alertType { - case .confirmCommand: - return Alert( - title: Text("Confirm Command"), - message: Text("New Target: \(Localizer.formatQuantity(newHKTarget)) \(Localizer.getPreferredUnit().localizedShortUnitString)\nDuration: \(Int(duration.doubleValue(for: HKUnit.minute()))) minutes"), - primaryButton: .default(Text("Confirm"), action: { - enactTempTarget() - }), - secondaryButton: .cancel() - ) - case .status: - return Alert( - title: Text("Status"), - message: Text(statusMessage ?? ""), - dismissButton: .default(Text("OK"), action: { - showAlert = false - }) - ) - case .confirmCancellation: - return Alert( - title: Text("Confirm Cancellation"), - message: Text("Are you sure you want to cancel the existing temp target?"), - primaryButton: .default(Text("Confirm"), action: { - cancelTempTarget() - }), - secondaryButton: .cancel() - ) - case .validation: - return Alert( - title: Text("Validation Error"), - message: Text(alertMessage ?? "Invalid input."), - dismissButton: .default(Text("OK")) - ) - case .none: - return Alert(title: Text("Unknown Alert")) - } - } - .sheet(isPresented: $showPresetSheet) { - VStack { - Text("Save Preset") - .font(.headline) - .padding() - TextField("Preset Name", text: $presetName) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .padding() - HStack { - Button("Cancel") { - showPresetSheet = false - } - .padding() - Spacer() - Button("Save") { - presetManager.addPreset(name: presetName, target: newHKTarget, duration: duration) - presetName = "" - showPresetSheet = false - } - .disabled(presetName.isEmpty) - .padding() - } - Spacer() - } - .padding() - } - } - } - - private var isButtonDisabled: Bool { - return newHKTarget.doubleValue(for: Localizer.getPreferredUnit()) == 0 || - duration.doubleValue(for: HKUnit.minute()) == 0 || isLoading - } - - private func enactTempTarget() { - isLoading = true - remoteController.sendTempTarget(newTarget: newHKTarget, duration: duration) { success in - DispatchQueue.main.async { - self.isLoading = false - if success { - self.statusMessage = "Command successfully sent to Nightscout." - LogManager.shared.log( - category: .nightscout, - message: "sendTempTarget succeeded - New Target: \(Localizer.formatQuantity(newHKTarget)) \(Localizer.getPreferredUnit().localizedShortUnitString), Duration: \(Int(duration.doubleValue(for: HKUnit.minute()))) minutes" - ) - } else { - self.statusMessage = "Failed to enact target." - LogManager.shared.log( - category: .nightscout, - message: "sendTempTarget failed - New Target: \(Localizer.formatQuantity(newHKTarget)) \(Localizer.getPreferredUnit().localizedShortUnitString), Duration: \(Int(duration.doubleValue(for: HKUnit.minute()))) minutes" - ) - } - self.alertType = .status - self.showAlert = true - } - } - } - - private func cancelTempTarget() { - isLoading = true - remoteController.cancelExistingTarget { success in - DispatchQueue.main.async { - self.isLoading = false - if success { - self.statusMessage = "Cancellation request successfully sent to Nightscout." - LogManager.shared.log( - category: .nightscout, - message: "cancelExistingTarget succeeded" - ) - } else { - self.statusMessage = "Failed to cancel temp target." - LogManager.shared.log( - category: .nightscout, - message: "cancelExistingTarget failed" - ) - } - self.alertType = .status - self.showAlert = true - } - } - } - - private func handleValidationError(_ message: String) { - alertMessage = message - alertType = .validation - showAlert = true - } -} diff --git a/LoopFollow/Remote/NoRemoteView.swift b/LoopFollow/Remote/NoRemoteView.swift deleted file mode 100644 index 9daf12c51..000000000 --- a/LoopFollow/Remote/NoRemoteView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// LoopFollow -// NoRemoteView.swift - -import SwiftUI - -struct NoRemoteView: View { - private let remoteController = TrioNightscoutRemoteController() - - var body: some View { - NavigationView { - VStack { - ErrorMessageView( - message: "Remote commands are currently only available for Trio and Loop." - ) - } - } - } -} diff --git a/LoopFollow/Remote/RemoteContentView.swift b/LoopFollow/Remote/RemoteContentView.swift index d5694801e..6cc534e70 100644 --- a/LoopFollow/Remote/RemoteContentView.swift +++ b/LoopFollow/Remote/RemoteContentView.swift @@ -10,13 +10,6 @@ struct RemoteContentView: View { var body: some View { Group { switch remoteType.value { - case .nightscout: - if device.value == "Trio" { - TrioNightscoutRemoteView() - } else { - NoRemoteView() - } - case .trc: if device.value == "Trio" { TrioRemoteControlView(viewModel: TrioRemoteControlViewModel()) @@ -31,18 +24,5 @@ struct RemoteContentView: View { Text("Please select a Remote Type in Settings.") } } - .onAppear { - verifyNightscoutAuth() - } - } - - private func verifyNightscoutAuth() { - guard remoteType.value == .nightscout, !Storage.shared.nsWriteAuth.value else { return } - NightscoutUtils.verifyURLAndToken { _, _, nsWriteAuth, nsAdminAuth in - DispatchQueue.main.async { - Storage.shared.nsWriteAuth.value = nsWriteAuth - Storage.shared.nsAdminAuth.value = nsAdminAuth - } - } } } diff --git a/LoopFollow/Remote/RemoteType.swift b/LoopFollow/Remote/RemoteType.swift index 1e4b958dd..4ace7c41f 100644 --- a/LoopFollow/Remote/RemoteType.swift +++ b/LoopFollow/Remote/RemoteType.swift @@ -5,7 +5,6 @@ import Foundation enum RemoteType: String, Codable { case none = "None" - case nightscout = "Nightscout" case trc = "Trio Remote Control" case loopAPNS = "Loop APNS" } diff --git a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift index 56c5686fb..ddfe9203b 100644 --- a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift +++ b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift @@ -111,10 +111,6 @@ struct RemoteCommandSettings: Codable { storage.device.value = "Loop" case .trc: storage.device.value = "Trio" - case .nightscout: - // For Nightscout, we don't automatically set device type - // as it should be determined by the actual connection - break case .none: // For none, we don't change the device type break @@ -149,8 +145,6 @@ struct RemoteCommandSettings: Codable { switch remoteType { case .none: return true - case .nightscout: - return !user.isEmpty case .trc: return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 670fb66c9..879ca047a 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -64,16 +64,6 @@ struct RemoteSettingsView: View { label: "Trio Remote Control", isEnabled: viewModel.isTrioDevice ) - - remoteTypeRow( - type: .nightscout, - label: "Nightscout", - isEnabled: viewModel.isTrioDevice - ) - - Text("Nightscout should be used for Trio 0.2.x.") - .font(.footnote) - .foregroundColor(.secondary) } // MARK: - Import/Export Settings Section @@ -131,7 +121,7 @@ struct RemoteSettingsView: View { // MARK: - User Information Section - if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS { + if viewModel.remoteType == .trc { Section(header: Text("User Information")) { HStack { Text("User") diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 48d380f7c..2b3ab9af0 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -63,7 +63,7 @@ class RemoteSettingsViewModel: ObservableObject { } return loopFollowTeamID != targetTeamId - case .none, .nightscout: + case .none: return false } } diff --git a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift b/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift deleted file mode 100644 index 594619690..000000000 --- a/LoopFollow/Remote/TRC/TrioNightscoutRemoteController.swift +++ /dev/null @@ -1,51 +0,0 @@ -// LoopFollow -// TrioNightscoutRemoteController.swift - -import Foundation -import HealthKit - -class TrioNightscoutRemoteController { - func cancelExistingTarget(completion: @escaping (Bool) -> Void) { - Task { - let tempTargetBody: [String: Any] = [ - "enteredBy": "LoopFollow", - "eventType": "Temporary Target", - "reason": "Manual", - "duration": 0, - "created_at": ISO8601DateFormatter().string(from: Date()), - ] - - do { - let response: [TreatmentCancelResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) - Observable.shared.tempTarget.value = nil - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - completion(true) - } catch { - completion(false) - } - } - } - - func sendTempTarget(newTarget: HKQuantity, duration: HKQuantity, completion: @escaping (Bool) -> Void) { - let tempTargetBody: [String: Any] = [ - "enteredBy": "LoopFollow", - "eventType": "Temporary Target", - "reason": "Manual", - "targetTop": newTarget.doubleValue(for: .milligramsPerDeciliter), - "targetBottom": newTarget.doubleValue(for: .milligramsPerDeciliter), - "duration": Int(duration.doubleValue(for: .minute())), - "created_at": ISO8601DateFormatter().string(from: Date()), - ] - - Task { - do { - let response: [TreatmentResponse] = try await NightscoutUtils.executePostRequest(eventType: .treatments, body: tempTargetBody) - Observable.shared.tempTarget.value = newTarget - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - completion(true) - } catch { - completion(false) - } - } - } -} diff --git a/LoopFollow/Settings/ImportExport/ExportableSettings.swift b/LoopFollow/Settings/ImportExport/ExportableSettings.swift index a8c3fdd7a..cd222417c 100644 --- a/LoopFollow/Settings/ImportExport/ExportableSettings.swift +++ b/LoopFollow/Settings/ImportExport/ExportableSettings.swift @@ -294,10 +294,6 @@ struct RemoteSettingsExport: Codable { storage.device.value = "Loop" case .trc: storage.device.value = "Trio" - case .nightscout: - // For Nightscout, we don't automatically set device type - // as it should be determined by the actual connection - break case .none: break } @@ -317,8 +313,6 @@ struct RemoteSettingsExport: Codable { switch remoteType { case .none: return true - case .nightscout: - return !user.isEmpty case .trc: return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index c93f72f89..105873f1c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -381,8 +381,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio // Check if current remote type is invalid for the device let shouldReset = (currentRemoteType == .loopAPNS && !isLoopDevice) || - (currentRemoteType == .trc && !isTrioDevice) || - (currentRemoteType == .nightscout && !isTrioDevice) + (currentRemoteType == .trc && !isTrioDevice) if shouldReset { Storage.shared.remoteType.value = .none From 17991b7aeb9063aaf8da3f50844e656e760d86ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 03:16:14 +0000 Subject: [PATCH 15/22] CI: Bump dev version to 6.1.9 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index e6b561186..10e8bdee7 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.8 +LOOP_FOLLOW_MARKETING_VERSION = 6.1.9 From 3aeeeb0f411e5b5b8d2c39cd87fbd0e41398ddd1 Mon Sep 17 00:00:00 2001 From: quarktwain <51277869+quarktwain@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:14:51 -0400 Subject: [PATCH 16/22] Cap prediction cone to shortest predBG array length (#653) Fixes #637. The cone of uncertainty in updateOpenAPSPredictionDisplay() was capped at the longest predBG array (.max()), which let the band visibly deform at the tail as shorter arrays dropped out one by one. Cap at the shortest array length instead so every cone point is computed from the same set of contributing arrays. Matches Trio's ForecastSetup (Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift), which uses allForecastValues.map(\.count).min() and then iterates 0 ..< localMinCount. Renamed maxLength to coneLength since the variable no longer represents a max. --- LoopFollow/Controllers/Graphs.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index 8701be7cf..184b67b8f 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -2062,9 +2062,11 @@ extension MainViewController { var coneData = [ConeChartDataEntry]() if !allArrays.isEmpty { - let maxLength = min(allArrays.map { $0.count }.max()!, toLoad + 1) + // Cap at the shortest predBG array length so every cone point uses + // the same set of contributing arrays. Matches Trio's ForecastSetup. + let coneLength = min(allArrays.map { $0.count }.min()!, toLoad + 1) var t = predictionStart - for i in 0 ..< maxLength { + for i in 0 ..< coneLength { var valuesAtIndex = [Double]() for arr in allArrays where i < arr.count { valuesAtIndex.append(arr[i]) From d333202cd89d786e015af70952c8120a89b55922 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 05:15:07 +0000 Subject: [PATCH 17/22] CI: Bump dev version to 6.1.10 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 10e8bdee7..c73b5c659 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.9 +LOOP_FOLLOW_MARKETING_VERSION = 6.1.10 From 86fd0059d7f14d88a7933480cc557cc2eb2f17eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 17 Jun 2026 21:47:30 +0200 Subject: [PATCH 18/22] CI: Re-run lint when a PR's base branch changes (#676) Add 'edited' to the lint workflow's pull_request trigger, guarded so the SwiftFormat job only re-runs when the base branch actually changed. Retargeting a PR (e.g. main -> dev) fires pull_request: edited, which the default trigger types (opened, synchronize, reopened) ignore. When the new base requires the SwiftFormat status check, GitHub leaves it 'Expected - waiting for status to be reported' with no run behind it, blocking the PR indefinitely. Re-running on base change ensures the required check actually reports against the new base. --- .github/workflows/lint.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 68aa860f6..8b1c217f3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,10 @@ run-name: Lint (${{ github.head_ref || github.ref_name }}) on: pull_request: + # 'edited' so the lint re-runs when a PR's base branch is changed; otherwise + # retargeting (e.g. main -> dev) leaves the required SwiftFormat check with no + # run on the new base and the PR stays blocked indefinitely. + types: [opened, synchronize, reopened, edited] workflow_dispatch: concurrency: @@ -15,6 +19,9 @@ permissions: jobs: swiftformat: name: SwiftFormat + # Run on open/sync/reopen always; on 'edited' only when the base branch + # actually changed, so routine title/description edits don't re-lint. + if: ${{ github.event.action != 'edited' || github.event.changes.base != null }} runs-on: ubuntu-latest container: swift:6.0 steps: From d6ce28346a50670e7ad8c61a4076e9772cefe36b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 19:47:52 +0000 Subject: [PATCH 19/22] CI: Bump dev version to 6.1.11 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index c73b5c659..bc2f82450 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.10 +LOOP_FOLLOW_MARKETING_VERSION = 6.1.11 From 5fc2c4eee89ccbe9769888bd29d1db14c6483789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 17 Jun 2026 21:51:41 +0200 Subject: [PATCH 20/22] Remove empty Nightscout group from project file (#675) The Nightscout remote command path was removed in #618, deleting the only file under LoopFollow/Remote/Nightscout/. The now-empty PBXGroup was left behind, still pointing at a folder with no contents. Remove it. --- LoopFollow.xcodeproj/project.pbxproj | 8 -------- 1 file changed, 8 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index b2f706d3f..b162633e0 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -1092,7 +1092,6 @@ children = ( B500000000000000000000A5 /* QuickPickBoluses */, B500000000000000000000B5 /* QuickPickMeals */, - DDDF6F4A2D479B6A00884336 /* Nightscout */, DDEF503E2D479B8A00884336 /* LoopAPNS */, DD4878112C7B74F90048F05C /* TRC */, DD4878062C7B2E9E0048F05C /* Settings */, @@ -1388,13 +1387,6 @@ path = AddAlarm; sourceTree = ""; }; - DDDF6F4A2D479B6A00884336 /* Nightscout */ = { - isa = PBXGroup; - children = ( - ); - path = Nightscout; - sourceTree = ""; - }; DDEF503D2D32753A00999A5D /* Task */ = { isa = PBXGroup; children = ( From 3e639295c1da7f548af1bb6267ba38b7488b3600 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 19:51:53 +0000 Subject: [PATCH 21/22] CI: Bump dev version to 6.1.12 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index bc2f82450..43d6011d2 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.11 +LOOP_FOLLOW_MARKETING_VERSION = 6.1.12 From 5c5ff91f35a72f0f884955f6c26717744b3093f0 Mon Sep 17 00:00:00 2001 From: marionbarker Date: Sun, 21 Jun 2026 08:10:14 -0700 Subject: [PATCH 22/22] update version to 6.2.0 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 43d6011d2..9b34855d4 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 6.1.12 +LOOP_FOLLOW_MARKETING_VERSION = 6.2.0