From 6bf94ebbc008d5490cbc380eeb842c9b4b244806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 10:41:01 +0200 Subject: [PATCH 01/14] 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. --- LoopFollow.xcodeproj/project.pbxproj | 56 +- LoopFollow/Application/AppDelegate.swift | 44 +- .../Application/Base.lproj/Main.storyboard | 456 --------------- LoopFollow/Application/LoopFollowApp.swift | 24 + LoopFollow/Application/MainTabView.swift | 63 ++ LoopFollow/Application/SceneDelegate.swift | 83 --- LoopFollow/Info.plist | 23 +- LoopFollow/Remote/RemoteContentView.swift | 49 ++ LoopFollow/Remote/RemoteViewController.swift | 126 ---- LoopFollow/Settings/HomeContentView.swift | 21 +- .../Snoozer/SnoozerViewController.swift | 65 --- LoopFollow/Storage/Observable.swift | 3 + .../ViewControllers/AlarmViewController.swift | 60 -- .../ViewControllers/MainViewController.swift | 542 +++++++----------- LoopFollow/ViewControllers/MoreMenuView.swift | 217 +++++++ .../MoreMenuViewController.swift | 466 --------------- .../NightScoutViewController.swift | 18 +- .../NightscoutContentView.swift | 14 + .../SettingsViewController.swift | 73 --- 19 files changed, 647 insertions(+), 1756 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/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/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 9a11022d6..fe7feb5bf 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -86,9 +86,10 @@ 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 */; }; @@ -97,7 +98,8 @@ 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 */; }; + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */; }; DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; }; @@ -215,7 +217,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 */; }; @@ -404,10 +405,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 */; }; @@ -424,7 +423,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 */ @@ -535,9 +533,10 @@ 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 = ""; }; @@ -546,7 +545,8 @@ 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 = ""; }; + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutContentView.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = ""; }; @@ -665,7 +665,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 = ""; }; @@ -857,10 +856,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 = ""; }; @@ -880,7 +877,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 */ @@ -1043,7 +1039,7 @@ DD4878112C7B74F90048F05C /* TRC */, DD4878062C7B2E9E0048F05C /* Settings */, DDF699972C5AA2E50058A8D9 /* TempTargetPreset */, - DD0C0C6F2C4AFFE800DBADDF /* RemoteViewController.swift */, + DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */, DDE69ED12C7256260013EAEC /* RemoteType.swift */, ); path = Remote; @@ -1219,7 +1215,6 @@ children = ( DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */, DDC7E5122DBCE1B900EB1127 /* SnoozerView.swift */, - DDC7E5132DBCE1B900EB1127 /* SnoozerViewController.swift */, ); path = Snoozer; sourceTree = ""; @@ -1427,9 +1422,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; @@ -1702,11 +1697,10 @@ FCC68871248A736700A0279D /* ViewControllers */ = { isa = PBXGroup; children = ( - DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */, - DD12D4842E1705D9004E0112 /* AlarmViewController.swift */, + CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */, + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */, FC97881B2485969B00A7906C /* MainViewController.swift */, FC97881D2485969B00A7906C /* NightScoutViewController.swift */, - FCFEECA1248857A600402A7F /* SettingsViewController.swift */, ); path = ViewControllers; sourceTree = ""; @@ -1945,7 +1939,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 */, @@ -2129,7 +2122,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 */, @@ -2191,7 +2183,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 */, @@ -2202,7 +2193,7 @@ DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */, DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */, DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */, - DD0C0C702C4AFFE800DBADDF /* RemoteViewController.swift in Sources */, + DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */, DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */, FCFEECA02488157B00402A7F /* Chart.swift in Sources */, DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */, @@ -2222,7 +2213,8 @@ 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 */, + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */, 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */, DD4878152C7B75230048F05C /* MealView.swift in Sources */, FC16A97F249969E2003D6245 /* Graphs.swift in Sources */, @@ -2292,7 +2284,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 */, @@ -2318,7 +2311,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 */, @@ -2391,14 +2383,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/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index a6fd9f2b9..32ee9d3a9 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -1,12 +1,12 @@ // LoopFollow // AppDelegate.swift +import AVFoundation import CoreData import EventKit import UIKit import UserNotifications -@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() @@ -111,32 +111,28 @@ 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 } - 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) - } + // MARK: - Quick Actions - 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. + func application(_: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + completionHandler(false) + return + } + 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) + AVSpeechSynthesizer().speak(utterance) + completionHandler(true) + } else { + completionHandler(false) + } } // MARK: - Core Data stack @@ -186,10 +182,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { 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) - } + // Switch to Home tab + Observable.shared.selectedTabIndex.value = 0 } if response.actionIdentifier == "snooze" { diff --git a/LoopFollow/Application/Base.lproj/Main.storyboard b/LoopFollow/Application/Base.lproj/Main.storyboard deleted file mode 100644 index 11e4b1a72..000000000 --- a/LoopFollow/Application/Base.lproj/Main.storyboard +++ /dev/null @@ -1,456 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LoopFollow/Application/LoopFollowApp.swift b/LoopFollow/Application/LoopFollowApp.swift new file mode 100644 index 000000000..bd077d477 --- /dev/null +++ b/LoopFollow/Application/LoopFollowApp.swift @@ -0,0 +1,24 @@ +// LoopFollow +// LoopFollowApp.swift + +import AVFoundation +import SwiftUI + +@main +struct LoopFollowApp: App { + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + + private let synthesizer = AVSpeechSynthesizer() + + var body: some Scene { + WindowGroup { + MainTabView() + .onOpenURL { url in + guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return } + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + } + } +} diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift new file mode 100644 index 000000000..a31675d98 --- /dev/null +++ b/LoopFollow/Application/MainTabView.swift @@ -0,0 +1,63 @@ +// LoopFollow +// MainTabView.swift + +import Combine +import SwiftUI + +struct MainTabView: View { + @ObservedObject private var selectedTab = Observable.shared.selectedTabIndex + @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 + + 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(Storage.shared.appearanceMode.value.colorScheme) + } + + @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: nil) + } + } + } +} diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift deleted file mode 100644 index e702db267..000000000 --- a/LoopFollow/Application/SceneDelegate.swift +++ /dev/null @@ -1,83 +0,0 @@ -// LoopFollow -// SceneDelegate.swift - -import AVFoundation -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - let synthesizer = AVSpeechSynthesizer() - - 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. - } - - 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. - DispatchQueue.main.async { - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } - } - - 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/Info.plist b/LoopFollow/Info.plist index 9e0f99340..dd64ccb3b 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,9 +78,7 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities +UIRequiredDeviceCapabilities armv7 diff --git a/LoopFollow/Remote/RemoteContentView.swift b/LoopFollow/Remote/RemoteContentView.swift new file mode 100644 index 000000000..3043081a4 --- /dev/null +++ b/LoopFollow/Remote/RemoteContentView.swift @@ -0,0 +1,49 @@ +// LoopFollow +// RemoteContentView.swift + +import Combine +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/HomeContentView.swift b/LoopFollow/Settings/HomeContentView.swift index fe88cb569..0527aedd7 100644 --- a/LoopFollow/Settings/HomeContentView.swift +++ b/LoopFollow/Settings/HomeContentView.swift @@ -14,28 +14,9 @@ struct HomeContentView: UIViewControllerRepresentable { } 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 - } - + let mainVC = MainViewController() mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - mainVC.isPresentedAsModal = isModal - return mainVC } 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/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index f32887523..7aa5fc736 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -46,5 +46,8 @@ class Observable { 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/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/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ac1f19a24..6f9c1c3c8 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -26,27 +26,25 @@ private struct APNSCredentialSnapshot: Equatable { 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 statsEstA1C: UILabel! - @IBOutlet var statsStdDev: UILabel! - @IBOutlet var serverText: UILabel! - @IBOutlet var statsView: UIView! - @IBOutlet var smallGraphHeightConstraint: NSLayoutConstraint! + var BGText: UILabel! + var DeltaText: UILabel! + var DirectionText: UILabel! + var BGChart: LineChartView! + var BGChartFull: LineChartView! + var MinAgoText: UILabel! + var infoTable: UITableView! + var PredictionLabel: UILabel! + var LoopStatusLabel: UILabel! + var statsPieChart: PieChartView! + var statsLowPercent: UILabel! + var statsInRangePercent: UILabel! + var statsHighPercent: UILabel! + var statsAvgBG: UILabel! + var statsEstA1C: UILabel! + var statsStdDev: UILabel! + var serverText: UILabel! + var statsView: UIView! + var smallGraphHeightConstraint: NSLayoutConstraint! var refreshScrollView: UIScrollView! var refreshControl: UIRefreshControl! @@ -133,7 +131,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let contactImageUpdater = ContactImageUpdater() private var cancellables = Set() - private var isViewHierarchyReady = false // Loading state management private var loadingOverlay: UIView? @@ -145,9 +142,191 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele ] private var loadingTimeoutTimer: Timer? + // MARK: - Programmatic UI Setup + + private func setupUI() { + view.backgroundColor = .systemBackground + + // --- Top section: BG display + info table (horizontal stack) --- + + serverText = UILabel() + serverText.font = .systemFont(ofSize: 13) + serverText.textAlignment = .center + serverText.text = "Server" + + BGText = UILabel() + BGText.font = .systemFont(ofSize: 85, weight: .black) + BGText.textAlignment = .center + BGText.text = "BG" + BGText.setContentCompressionResistancePriority(.required, for: .horizontal) + + DirectionText = UILabel() + DirectionText.font = .systemFont(ofSize: 60, weight: .black) + DirectionText.textAlignment = .right + DirectionText.text = "--" + DirectionText.setContentCompressionResistancePriority(.required, for: .horizontal) + + DeltaText = UILabel() + DeltaText.font = .systemFont(ofSize: 32) + DeltaText.textAlignment = .left + DeltaText.text = "Delta" + DeltaText.setContentCompressionResistancePriority(.required, for: .horizontal) + + let directionDeltaStack = UIStackView(arrangedSubviews: [DirectionText, DeltaText]) + directionDeltaStack.axis = .horizontal + directionDeltaStack.distribution = .fillEqually + + MinAgoText = UILabel() + MinAgoText.font = .systemFont(ofSize: 17) + MinAgoText.textAlignment = .center + MinAgoText.text = "MinAgo" + + LoopStatusLabel = UILabel() + LoopStatusLabel.font = .systemFont(ofSize: 17) + LoopStatusLabel.textAlignment = .right + LoopStatusLabel.text = "" + + PredictionLabel = UILabel() + PredictionLabel.font = .systemFont(ofSize: 17) + PredictionLabel.textAlignment = .left + PredictionLabel.text = "" + + let loopPredictionStack = UIStackView(arrangedSubviews: [LoopStatusLabel, PredictionLabel]) + loopPredictionStack.axis = .horizontal + loopPredictionStack.distribution = .fillEqually + loopPredictionStack.spacing = UIStackView.spacingUseSystem + + let bgViewStack = UIStackView(arrangedSubviews: [serverText, BGText, directionDeltaStack, MinAgoText, loopPredictionStack]) + bgViewStack.axis = .vertical + + infoTable = UITableView(frame: .zero, style: .plain) + infoTable.backgroundColor = .systemBackground + infoTable.translatesAutoresizingMaskIntoConstraints = false + let tableWidthConstraint = infoTable.widthAnchor.constraint(equalToConstant: 250) + tableWidthConstraint.priority = .defaultHigh + tableWidthConstraint.isActive = true + + let topStack = UIStackView(arrangedSubviews: [bgViewStack, infoTable]) + topStack.axis = .horizontal + topStack.spacing = 10 + topStack.translatesAutoresizingMaskIntoConstraints = false + topStack.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + // --- Bottom section: charts + stats (vertical stack) --- + + BGChart = LineChartView() + BGChart.backgroundColor = .systemBackground + BGChart.setContentHuggingPriority(.defaultHigh, for: .horizontal) + BGChart.setContentHuggingPriority(.defaultHigh, for: .vertical) + BGChart.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + BGChartFull = LineChartView() + BGChartFull.backgroundColor = .systemBackground + BGChartFull.autoresizesSubviews = false + BGChartFull.setContentCompressionResistancePriority(.required, for: .vertical) + smallGraphHeightConstraint = BGChartFull.heightAnchor.constraint(equalToConstant: 40) + smallGraphHeightConstraint.isActive = true + + // Stats view + statsView = UIView() + statsView.backgroundColor = .secondarySystemBackground + statsView.setContentCompressionResistancePriority(.required, for: .vertical) + let statsHeightConstraint = statsView.heightAnchor.constraint(equalToConstant: 100) + statsHeightConstraint.isActive = true + + statsPieChart = PieChartView() + statsPieChart.backgroundColor = .clear + statsPieChart.isUserInteractionEnabled = false + statsPieChart.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + statsPieChart.widthAnchor.constraint(equalToConstant: 100), + statsPieChart.heightAnchor.constraint(equalToConstant: 100), + ]) + + // Stats labels + func makeStatColumn(title: String, valueLabel: inout UILabel!) -> UIStackView { + let titleLabel = UILabel() + titleLabel.font = .systemFont(ofSize: 15) + titleLabel.text = title + + valueLabel = UILabel() + valueLabel!.font = .systemFont(ofSize: 15) + valueLabel!.text = "" + + let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel!]) + stack.axis = .vertical + stack.alignment = .center + return stack + } + + let lowColumn = makeStatColumn(title: "Low:", valueLabel: &statsLowPercent) + let inRangeColumn = makeStatColumn(title: "In Range:", valueLabel: &statsInRangePercent) + let highColumn = makeStatColumn(title: "High:", valueLabel: &statsHighPercent) + + let statsRow1 = UIStackView(arrangedSubviews: [lowColumn, inRangeColumn, highColumn]) + statsRow1.axis = .horizontal + statsRow1.distribution = .fillEqually + statsRow1.alignment = .top + statsRow1.spacing = 10 + + let avgBGColumn = makeStatColumn(title: "Avg BG:", valueLabel: &statsAvgBG) + let estA1CColumn = makeStatColumn(title: "Est A1C:", valueLabel: &statsEstA1C) + let stdDevColumn = makeStatColumn(title: "Std Dev:", valueLabel: &statsStdDev) + + let statsRow2 = UIStackView(arrangedSubviews: [avgBGColumn, estA1CColumn, stdDevColumn]) + statsRow2.axis = .horizontal + statsRow2.distribution = .fillEqually + statsRow2.alignment = .top + statsRow2.spacing = 10 + + let statsLabelsStack = UIStackView(arrangedSubviews: [statsRow1, statsRow2]) + statsLabelsStack.axis = .vertical + statsLabelsStack.distribution = .fillEqually + statsLabelsStack.spacing = 10 + + let statsContentStack = UIStackView(arrangedSubviews: [statsPieChart, statsLabelsStack]) + statsContentStack.axis = .horizontal + statsContentStack.alignment = .center + statsContentStack.translatesAutoresizingMaskIntoConstraints = false + + statsView.addSubview(statsContentStack) + NSLayoutConstraint.activate([ + statsContentStack.leadingAnchor.constraint(equalTo: statsView.leadingAnchor), + statsContentStack.trailingAnchor.constraint(equalTo: statsView.trailingAnchor), + statsContentStack.topAnchor.constraint(equalTo: statsView.topAnchor), + statsContentStack.bottomAnchor.constraint(equalTo: statsView.bottomAnchor), + ]) + + let bottomStack = UIStackView(arrangedSubviews: [BGChart, BGChartFull, statsView]) + bottomStack.axis = .vertical + bottomStack.spacing = 8 + bottomStack.setContentHuggingPriority(.defaultHigh, for: .horizontal) + bottomStack.setContentHuggingPriority(.required, for: .vertical) + bottomStack.translatesAutoresizingMaskIntoConstraints = false + + // --- Add to view and constrain --- + + view.addSubview(topStack) + view.addSubview(bottomStack) + + let safeArea = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + topStack.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 8), + topStack.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 8), + topStack.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -8), + + bottomStack.topAnchor.constraint(equalTo: topStack.bottomAnchor, constant: 8), + bottomStack.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 8), + bottomStack.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -8), + bottomStack.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -8), + ]) + } + override func viewDidLoad() { super.viewDidLoad() + setupUI() + loadDebugData() // Migrations run in foreground only — see runMigrationsIfNeeded() for details. @@ -253,13 +432,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele 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) @@ -334,26 +511,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) @@ -424,13 +584,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 @@ -543,199 +696,28 @@ 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 method kept for backward compatibility — with SwiftUI TabView, + /// tab rebuilding is handled reactively by MainTabView. 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() + // No-op: SwiftUI TabView observes Storage position changes directly } @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 + Observable.shared.selectedTabIndex.value = 0 } } - 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 - } - } - - 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 @@ -747,94 +729,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" @@ -935,7 +829,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell", for: indexPath) + let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell") + ?? UITableViewCell(style: .value1, reuseIdentifier: "LabelCell") + cell.detailTextLabel?.textColor = .label if indexPath.row == 0, let timeZoneOverrideInfoValue { cell.textLabel?.text = "Time Zone" diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift new file mode 100644 index 000000000..034c70a46 --- /dev/null +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -0,0 +1,217 @@ +// LoopFollow +// MoreMenuView.swift + +import SwiftUI + +struct MoreMenuView: View { + @State private var latestVersion: String? + @State private var versionTint: Color = .secondary + @State private var showShareSheet = false + @State private var shareFiles: [URL] = [] + @State private var alertTitle = "" + @State private var alertMessage = "" + @State private var showAlert = false + @State private var showSettingsView = false + @State private var showAlarmsView = false + @State private var showRemoteView = false + @State private var showNightscoutView = false + @State private var showSnoozerView = false + @State private var showTreatmentsView = false + @State private var showStatsView = false + @State private var showHomeView = false + @State private var showLogView = false + + var body: some View { + List { + // Settings + Section { + Button { showSettingsView = true } label: { + Label("Settings", systemImage: "gearshape") + .foregroundStyle(.primary) + } + } + + // Features + Section("Features") { + ForEach(TabItem.featureOrder) { item in + Button { openItem(item) } label: { + Label(item.displayName, systemImage: item.icon) + .foregroundStyle(.primary) + } + } + } + + // Logging + Section("Logging") { + Button { showLogView = true } label: { + Label("View Log", systemImage: "doc.text.magnifyingglass") + .foregroundStyle(.primary) + } + + Button { shareLogs() } label: { + Label("Share Logs", systemImage: "square.and.arrow.up") + .foregroundStyle(.primary) + } + } + + // 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: AppVersionManager().version(), 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() + } + .sheet(isPresented: $showShareSheet) { + ActivityView(activityItems: shareFiles) + } + .alert(alertTitle, isPresented: $showAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage) + } + .navigationDestination(isPresented: $showSettingsView) { + SettingsMenuView() + .onDisappear { + MainViewController.rebuildTabsIfNeeded() + } + } + .navigationDestination(isPresented: $showAlarmsView) { + AlarmsContainerView() + } + .navigationDestination(isPresented: $showRemoteView) { + RemoteContentView() + } + .navigationDestination(isPresented: $showNightscoutView) { + NightscoutContentView() + } + .navigationDestination(isPresented: $showSnoozerView) { + SnoozerView() + } + .navigationDestination(isPresented: $showTreatmentsView) { + TreatmentsView() + } + .navigationDestination(isPresented: $showStatsView) { + AggregatedStatsContentView(mainViewController: nil) + } + .navigationDestination(isPresented: $showHomeView) { + HomeContentView(isModal: true) + } + .navigationDestination(isPresented: $showLogView) { + LogView() + } + } + + // MARK: - Helpers + + private func buildInfoRow(title: String, value: String, color: Color) -> some View { + HStack { + Text(title) + Spacer() + Text(value) + .foregroundStyle(color) + } + } + + private func openItem(_ item: TabItem) { + // Check if the item is in the tab bar — if so, switch to it + let orderedItems = Storage.shared.orderedTabBarItems() + if let index = orderedItems.firstIndex(of: item) { + Observable.shared.selectedTabIndex.value = index + return + } + + // Otherwise push it onto the navigation stack + switch item { + case .home: showHomeView = true + case .alarms: showAlarmsView = true + case .remote: showRemoteView = true + case .nightscout: showNightscoutView = true + case .snoozer: showSnoozerView = true + case .treatments: showTreatmentsView = true + case .stats: showStatsView = true + } + } + + 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 + } + shareFiles = files + showShareSheet = true + } + + private func fetchVersionInfo() async { + let mgr = AppVersionManager() + let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync() + latestVersion = latest ?? "Unknown" + + let current = mgr.version() + versionTint = blacklisted ? .red + : newer ? .orange + : latest == current ? .green + : .secondary + } +} + +// MARK: - UIActivityViewController wrapper + +struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} 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 cecb29c68..e8d86d281 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) diff --git a/LoopFollow/ViewControllers/NightscoutContentView.swift b/LoopFollow/ViewControllers/NightscoutContentView.swift new file mode 100644 index 000000000..8f78f4769 --- /dev/null +++ b/LoopFollow/ViewControllers/NightscoutContentView.swift @@ -0,0 +1,14 @@ +// LoopFollow +// NightscoutContentView.swift + +import SwiftUI + +struct NightscoutContentView: 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 05992d9391024fe6407b8e6fc8eb3ba1b9073abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 10:53:01 +0200 Subject: [PATCH 02/14] 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. --- LoopFollow.xcodeproj/project.pbxproj | 4 + LoopFollow/InfoTable/InfoData.swift | 8 +- LoopFollow/InfoTable/InfoManager.swift | 43 +++------ LoopFollow/InfoTable/InfoTableView.swift | 34 +++++++ .../ViewControllers/MainViewController.swift | 94 ++++++++----------- 5 files changed, 97 insertions(+), 86 deletions(-) create mode 100644 LoopFollow/InfoTable/InfoTableView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index fe7feb5bf..46fe885c0 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ 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 */; }; @@ -541,6 +542,7 @@ 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 = ""; }; @@ -1052,6 +1054,7 @@ DD13BC762C3FD64E0062313B /* InfoData.swift */, DD13BC782C3FE63A0062313B /* InfoManager.swift */, DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */, + DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */, ); path = InfoTable; sourceTree = ""; @@ -2193,6 +2196,7 @@ DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */, DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */, DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */, + DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */, DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */, DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */, FCFEECA02488157B00402A7F /* Chart.swift in Sources */, 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..eaed97ada --- /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(.subheadline) + .frame(height: 21) + .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) + } +} diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 6f9c1c3c8..180c03662 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -23,7 +23,7 @@ private struct APNSCredentialSnapshot: Equatable { let lfKeyId: String } -class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { +class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { var isPresentedAsModal: Bool = false var BGText: UILabel! @@ -32,7 +32,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var BGChart: LineChartView! var BGChartFull: LineChartView! var MinAgoText: UILabel! - var infoTable: UITableView! + var infoTableContainer: UIView! var PredictionLabel: UILabel! var LoopStatusLabel: UILabel! var statsPieChart: PieChartView! @@ -199,14 +199,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let bgViewStack = UIStackView(arrangedSubviews: [serverText, BGText, directionDeltaStack, MinAgoText, loopPredictionStack]) bgViewStack.axis = .vertical - infoTable = UITableView(frame: .zero, style: .plain) - infoTable.backgroundColor = .systemBackground - infoTable.translatesAutoresizingMaskIntoConstraints = false - let tableWidthConstraint = infoTable.widthAnchor.constraint(equalToConstant: 250) + infoTableContainer = UIView() + infoTableContainer.translatesAutoresizingMaskIntoConstraints = false + let tableWidthConstraint = infoTableContainer.widthAnchor.constraint(equalToConstant: 250) tableWidthConstraint.priority = .defaultHigh tableWidthConstraint.isActive = true - let topStack = UIStackView(arrangedSubviews: [bgViewStack, infoTable]) + let topStack = UIStackView(arrangedSubviews: [bgViewStack, infoTableContainer]) topStack.axis = .horizontal topStack.spacing = 10 topStack.translatesAutoresizingMaskIntoConstraints = false @@ -335,13 +334,8 @@ 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) + infoManager = InfoManager() + setupInfoTableView() smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) view.layoutIfNeeded() @@ -493,14 +487,14 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.graphTimeZoneEnabled.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.infoTable.reloadData() + self?.updateInfoTableTimeZone() } .store(in: &cancellables) Storage.shared.graphTimeZoneIdentifier.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.infoTable.reloadData() + self?.updateInfoTableTimeZone() } .store(in: &cancellables) @@ -797,7 +791,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele override func viewWillAppear(_: Bool) { UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value - infoTable.reloadData() if Observable.shared.chartSettingsChanged.value { updateBGGraphSettings() @@ -809,6 +802,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } + private var infoTableHostingController: UIHostingController? + private var timeZoneOverrideInfoValue: String? { guard Storage.shared.graphTimeZoneEnabled.value, let overrideTimeZone = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) @@ -819,42 +814,31 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele 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") - ?? UITableViewCell(style: .value1, reuseIdentifier: "LabelCell") - cell.detailTextLabel?.textColor = .label - - if indexPath.row == 0, let timeZoneOverrideInfoValue { - cell.textLabel?.text = "Time Zone" - cell.detailTextLabel?.text = timeZoneOverrideInfoValue - return cell - } + private func setupInfoTableView() { + let infoTableView = InfoTableView( + infoManager: infoManager, + timeZoneOverride: timeZoneOverrideInfoValue + ) + let hosting = UIHostingController(rootView: infoTableView) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.backgroundColor = .clear + infoTableHostingController = hosting - let adjustedIndexPath: IndexPath - if timeZoneOverrideInfoValue != nil { - adjustedIndexPath = IndexPath(row: indexPath.row - 1, section: indexPath.section) - } else { - adjustedIndexPath = indexPath - } + addChild(hosting) + infoTableContainer.addSubview(hosting.view) + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: infoTableContainer.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: infoTableContainer.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: infoTableContainer.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: infoTableContainer.trailingAnchor), + ]) + hosting.didMove(toParent: self) - if let values = infoManager.dataForIndexPath(adjustedIndexPath) { - cell.textLabel?.text = values.name - cell.detailTextLabel?.text = values.value - } else { - cell.textLabel?.text = "" - cell.detailTextLabel?.text = "" - } + infoTableContainer.addBorder(toSide: .Left, withColor: UIColor.darkGray.cgColor, andThickness: 2) + } - return cell + private func updateInfoTableTimeZone() { + infoTableHostingController?.rootView.timeZoneOverride = timeZoneOverrideInfoValue } @objc func appMovedToBackground() { @@ -1078,10 +1062,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } else { PredictionLabel.isHidden = isHidden } - infoTable.isHidden = isHidden + infoTableContainer.isHidden = isHidden if Storage.shared.hideInfoTable.value { - infoTable.isHidden = true + infoTableContainer.isHidden = true } updateNightscoutTabState() @@ -1502,7 +1486,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele serverText.isHidden = true // Hide info table and stats - infoTable.isHidden = true + infoTableContainer.isHidden = true statsView.isHidden = true // Hide loop status and prediction @@ -1524,11 +1508,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Show/hide info table and stats based on user settings let isNightscoutEnabled = IsNightscoutEnabled() if isNightscoutEnabled { - infoTable.isHidden = Storage.shared.hideInfoTable.value + infoTableContainer.isHidden = Storage.shared.hideInfoTable.value LoopStatusLabel.isHidden = false PredictionLabel.isHidden = IsNotLooping } else { - infoTable.isHidden = true + infoTableContainer.isHidden = true LoopStatusLabel.isHidden = true PredictionLabel.isHidden = true } From 94e23c07f3d91c7e2814cf50e85a4537fa81e0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 11:02:01 +0200 Subject: [PATCH 03/14] 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(). --- LoopFollow.xcodeproj/project.pbxproj | 8 ++ .../MainViewController+updateStats.swift | 64 ++---------- .../Controllers/StatsDisplayModel.swift | 16 +++ LoopFollow/Controllers/StatsDisplayView.swift | 84 ++++++++++++++++ .../ViewControllers/MainViewController.swift | 98 +++++-------------- 5 files changed, 140 insertions(+), 130 deletions(-) create mode 100644 LoopFollow/Controllers/StatsDisplayModel.swift create mode 100644 LoopFollow/Controllers/StatsDisplayView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 46fe885c0..3f77b4e12 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -286,6 +286,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 */; }; @@ -736,6 +738,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 = ""; }; @@ -1443,6 +1447,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 */, @@ -2330,6 +2336,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 */, diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index 090f7925e..c1f2417d1 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,62 +20,20 @@ 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)) + 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)) if Storage.shared.useIFCC.value { - 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) } - statsStdDev.text = String(format: "%.2f", stats.stdDev) + statsDisplayModel.stdDev = String(format: "%.2f", stats.stdDev) - 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/StatsDisplayModel.swift b/LoopFollow/Controllers/StatsDisplayModel.swift new file mode 100644 index 000000000..1c29bccbc --- /dev/null +++ b/LoopFollow/Controllers/StatsDisplayModel.swift @@ -0,0 +1,16 @@ +// 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 stdDev: String = "" + @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..39fea3143 --- /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: "Est A1C:", value: model.estA1C) + statColumn(title: "Std Dev:", 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/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 180c03662..edc43e9d7 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -35,13 +35,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio var infoTableContainer: UIView! var PredictionLabel: UILabel! var LoopStatusLabel: UILabel! - var statsPieChart: PieChartView! - var statsLowPercent: UILabel! - var statsInRangePercent: UILabel! - var statsHighPercent: UILabel! - var statsAvgBG: UILabel! - var statsEstA1C: UILabel! - var statsStdDev: UILabel! + var statsDisplayModel = StatsDisplayModel() var serverText: UILabel! var statsView: UIView! var smallGraphHeightConstraint: NSLayoutConstraint! @@ -226,75 +220,12 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio smallGraphHeightConstraint = BGChartFull.heightAnchor.constraint(equalToConstant: 40) smallGraphHeightConstraint.isActive = true - // Stats view + // Stats view (SwiftUI hosted) statsView = UIView() - statsView.backgroundColor = .secondarySystemBackground statsView.setContentCompressionResistancePriority(.required, for: .vertical) let statsHeightConstraint = statsView.heightAnchor.constraint(equalToConstant: 100) statsHeightConstraint.isActive = true - - statsPieChart = PieChartView() - statsPieChart.backgroundColor = .clear - statsPieChart.isUserInteractionEnabled = false - statsPieChart.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - statsPieChart.widthAnchor.constraint(equalToConstant: 100), - statsPieChart.heightAnchor.constraint(equalToConstant: 100), - ]) - - // Stats labels - func makeStatColumn(title: String, valueLabel: inout UILabel!) -> UIStackView { - let titleLabel = UILabel() - titleLabel.font = .systemFont(ofSize: 15) - titleLabel.text = title - - valueLabel = UILabel() - valueLabel!.font = .systemFont(ofSize: 15) - valueLabel!.text = "" - - let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel!]) - stack.axis = .vertical - stack.alignment = .center - return stack - } - - let lowColumn = makeStatColumn(title: "Low:", valueLabel: &statsLowPercent) - let inRangeColumn = makeStatColumn(title: "In Range:", valueLabel: &statsInRangePercent) - let highColumn = makeStatColumn(title: "High:", valueLabel: &statsHighPercent) - - let statsRow1 = UIStackView(arrangedSubviews: [lowColumn, inRangeColumn, highColumn]) - statsRow1.axis = .horizontal - statsRow1.distribution = .fillEqually - statsRow1.alignment = .top - statsRow1.spacing = 10 - - let avgBGColumn = makeStatColumn(title: "Avg BG:", valueLabel: &statsAvgBG) - let estA1CColumn = makeStatColumn(title: "Est A1C:", valueLabel: &statsEstA1C) - let stdDevColumn = makeStatColumn(title: "Std Dev:", valueLabel: &statsStdDev) - - let statsRow2 = UIStackView(arrangedSubviews: [avgBGColumn, estA1CColumn, stdDevColumn]) - statsRow2.axis = .horizontal - statsRow2.distribution = .fillEqually - statsRow2.alignment = .top - statsRow2.spacing = 10 - - let statsLabelsStack = UIStackView(arrangedSubviews: [statsRow1, statsRow2]) - statsLabelsStack.axis = .vertical - statsLabelsStack.distribution = .fillEqually - statsLabelsStack.spacing = 10 - - let statsContentStack = UIStackView(arrangedSubviews: [statsPieChart, statsLabelsStack]) - statsContentStack.axis = .horizontal - statsContentStack.alignment = .center - statsContentStack.translatesAutoresizingMaskIntoConstraints = false - - statsView.addSubview(statsContentStack) - NSLayoutConstraint.activate([ - statsContentStack.leadingAnchor.constraint(equalTo: statsView.leadingAnchor), - statsContentStack.trailingAnchor.constraint(equalTo: statsView.trailingAnchor), - statsContentStack.topAnchor.constraint(equalTo: statsView.topAnchor), - statsContentStack.bottomAnchor.constraint(equalTo: statsView.bottomAnchor), - ]) + setupStatsView() let bottomStack = UIStackView(arrangedSubviews: [BGChart, BGChartFull, statsView]) bottomStack.axis = .vertical @@ -349,10 +280,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio 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 @@ -841,6 +768,25 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio infoTableHostingController?.rootView.timeZoneOverride = timeZoneOverrideInfoValue } + private func setupStatsView() { + let statsDisplayView = StatsDisplayView(model: statsDisplayModel) { [weak self] in + self?.statsViewTapped() + } + let hosting = UIHostingController(rootView: statsDisplayView) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.backgroundColor = .clear + + addChild(hosting) + statsView.addSubview(hosting.view) + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: statsView.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: statsView.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: statsView.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: statsView.trailingAnchor), + ]) + hosting.didMove(toParent: self) + } + @objc func appMovedToBackground() { // Allow screen to turn off UIApplication.shared.isIdleTimerDisabled = false From 69566eb8161b675ca2a3b7dcbdf24dfbcba7e64f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 11:19:17 +0200 Subject: [PATCH 04/14] 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. --- LoopFollow.xcodeproj/project.pbxproj | 4 + .../Controllers/Nightscout/BGData.swift | 4 +- .../Controllers/Nightscout/DeviceStatus.swift | 27 +-- .../Nightscout/DeviceStatusLoop.swift | 12 +- .../Nightscout/DeviceStatusOpenAPS.swift | 15 +- LoopFollow/Storage/Observable.swift | 6 + LoopFollow/Task/MinAgoTask.swift | 21 +- .../ViewControllers/BGDisplayView.swift | 80 +++++++ .../ViewControllers/MainViewController.swift | 201 +++--------------- 9 files changed, 146 insertions(+), 224 deletions(-) create mode 100644 LoopFollow/ViewControllers/BGDisplayView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 3f77b4e12..39607f8bd 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -100,6 +100,7 @@ DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.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 */; }; EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */; }; DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; @@ -550,6 +551,7 @@ DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.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 = ""; }; EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutContentView.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; @@ -1706,6 +1708,7 @@ FCC68871248A736700A0279D /* ViewControllers */ = { isa = PBXGroup; children = ( + DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */, CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */, EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */, FC97881B2485969B00A7906C /* MainViewController.swift */, @@ -2224,6 +2227,7 @@ FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */, DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */, CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */, + DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */, EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */, 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */, DD4878152C7B75230048F05C /* MealView.swift in Sources */, diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index d97aba24c..8211ec5b0 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -212,9 +212,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..9314fa1d4 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -36,7 +36,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 +46,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 +56,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..26ac32dde 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -18,7 +18,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 +67,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 +113,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 c1d926fd1..e4c2462cd 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 { let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [ @@ -233,15 +232,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/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 7aa5fc736..7ea134926 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) diff --git a/LoopFollow/Task/MinAgoTask.swift b/LoopFollow/Task/MinAgoTask.swift index 0ea011cee..b1166589a 100644 --- a/LoopFollow/Task/MinAgoTask.swift +++ b/LoopFollow/Task/MinAgoTask.swift @@ -15,9 +15,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 +44,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 +52,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/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift new file mode 100644 index 000000000..9a7382277 --- /dev/null +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -0,0 +1,80 @@ +// 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 bg = Observable.shared.bg + @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)? + + private var bgFontSize: CGFloat { + guard let bgValue = bg.value else { return 85 } + if bgValue <= globalVariables.minDisplayGlucose || bgValue >= globalVariables.maxDisplayGlucose { + return 65 + } + return 85 + } + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Text(serverText.value) + .font(.system(size: 13)) + + Text(bgText.value) + .font(.system(size: bgFontSize, weight: .black)) + .foregroundColor(bgTextColor.value) + .strikethrough( + bgStale.value, + pattern: .solid, + color: bgStale.value ? .red : .clear + ) + .frame(maxWidth: .infinity) + .minimumScaleFactor(0.5) + + HStack { + Text(directionText.value) + .font(.system(size: 60, weight: .black)) + Text(deltaText.value) + .font(.system(size: 32)) + } + + 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/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index edc43e9d7..ccf49c545 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -23,24 +23,16 @@ private struct APNSCredentialSnapshot: Equatable { let lfKeyId: String } -class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { +class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate { var isPresentedAsModal: Bool = false - var BGText: UILabel! - var DeltaText: UILabel! - var DirectionText: UILabel! var BGChart: LineChartView! var BGChartFull: LineChartView! - var MinAgoText: UILabel! var infoTableContainer: UIView! - var PredictionLabel: UILabel! - var LoopStatusLabel: UILabel! + var bgDisplayContainer: UIView! var statsDisplayModel = StatsDisplayModel() - var serverText: UILabel! var statsView: UIView! var smallGraphHeightConstraint: NSLayoutConstraint! - var refreshScrollView: UIScrollView! - var refreshControl: UIRefreshControl! // Setup buttons for first-time configuration private var setupNightscoutButton: UIButton! @@ -143,55 +135,8 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio // --- Top section: BG display + info table (horizontal stack) --- - serverText = UILabel() - serverText.font = .systemFont(ofSize: 13) - serverText.textAlignment = .center - serverText.text = "Server" - - BGText = UILabel() - BGText.font = .systemFont(ofSize: 85, weight: .black) - BGText.textAlignment = .center - BGText.text = "BG" - BGText.setContentCompressionResistancePriority(.required, for: .horizontal) - - DirectionText = UILabel() - DirectionText.font = .systemFont(ofSize: 60, weight: .black) - DirectionText.textAlignment = .right - DirectionText.text = "--" - DirectionText.setContentCompressionResistancePriority(.required, for: .horizontal) - - DeltaText = UILabel() - DeltaText.font = .systemFont(ofSize: 32) - DeltaText.textAlignment = .left - DeltaText.text = "Delta" - DeltaText.setContentCompressionResistancePriority(.required, for: .horizontal) - - let directionDeltaStack = UIStackView(arrangedSubviews: [DirectionText, DeltaText]) - directionDeltaStack.axis = .horizontal - directionDeltaStack.distribution = .fillEqually - - MinAgoText = UILabel() - MinAgoText.font = .systemFont(ofSize: 17) - MinAgoText.textAlignment = .center - MinAgoText.text = "MinAgo" - - LoopStatusLabel = UILabel() - LoopStatusLabel.font = .systemFont(ofSize: 17) - LoopStatusLabel.textAlignment = .right - LoopStatusLabel.text = "" - - PredictionLabel = UILabel() - PredictionLabel.font = .systemFont(ofSize: 17) - PredictionLabel.textAlignment = .left - PredictionLabel.text = "" - - let loopPredictionStack = UIStackView(arrangedSubviews: [LoopStatusLabel, PredictionLabel]) - loopPredictionStack.axis = .horizontal - loopPredictionStack.distribution = .fillEqually - loopPredictionStack.spacing = UIStackView.spacingUseSystem - - let bgViewStack = UIStackView(arrangedSubviews: [serverText, BGText, directionDeltaStack, MinAgoText, loopPredictionStack]) - bgViewStack.axis = .vertical + bgDisplayContainer = UIView() + bgDisplayContainer.translatesAutoresizingMaskIntoConstraints = false infoTableContainer = UIView() infoTableContainer.translatesAutoresizingMaskIntoConstraints = false @@ -199,7 +144,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio tableWidthConstraint.priority = .defaultHigh tableWidthConstraint.isActive = true - let topStack = UIStackView(arrangedSubviews: [bgViewStack, infoTableContainer]) + let topStack = UIStackView(arrangedSubviews: [bgDisplayContainer, infoTableContainer]) topStack.axis = .horizontal topStack.spacing = 10 topStack.translatesAutoresizingMaskIntoConstraints = false @@ -307,48 +252,9 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio 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 + setupBGDisplayView() 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) @@ -694,26 +600,12 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } } - 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) { @@ -729,6 +621,25 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } } + private func setupBGDisplayView() { + let bgDisplayView = BGDisplayView(onRefresh: { [weak self] in + self?.refresh() + }) + let hosting = UIHostingController(rootView: bgDisplayView) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.backgroundColor = .clear + + addChild(hosting) + bgDisplayContainer.addSubview(hosting.view) + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: bgDisplayContainer.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: bgDisplayContainer.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: bgDisplayContainer.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: bgDisplayContainer.trailingAnchor), + ]) + hosting.didMove(toParent: self) + } + private var infoTableHostingController: UIHostingController? private var timeZoneOverrideInfoValue: String? { @@ -997,22 +908,9 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio return } - var isHidden = false - if !IsNightscoutEnabled() { - isHidden = true - } + let isHidden = !IsNightscoutEnabled() - LoopStatusLabel.isHidden = isHidden - if IsNotLooping { - PredictionLabel.isHidden = true - } else { - PredictionLabel.isHidden = isHidden - } - infoTableContainer.isHidden = isHidden - - if Storage.shared.hideInfoTable.value { - infoTableContainer.isHidden = true - } + infoTableContainer.isHidden = isHidden || Storage.shared.hideInfoTable.value updateNightscoutTabState() } @@ -1029,29 +927,17 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio func updateBGTextAppearance() { if bgData.count > 0 { let latestBG = bgData[bgData.count - 1].sgv - var color = NSUIColor.label if Storage.shared.colorBGText.value { if Double(latestBG) >= Storage.shared.highLine.value { - color = NSUIColor.systemYellow Observable.shared.bgTextColor.value = .yellow } else if Double(latestBG) <= Storage.shared.lowLine.value { - 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) - } } } @@ -1424,44 +1310,21 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio 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 + // Hide BG display and info table + bgDisplayContainer.isHidden = true infoTableContainer.isHidden = true statsView.isHidden = true - - // Hide loop status and prediction - LoopStatusLabel.isHidden = true - PredictionLabel.isHidden = true } private func showAllDataUI() { - // Show BG display elements - BGText.isHidden = false - DeltaText.isHidden = false - DirectionText.isHidden = false - MinAgoText.isHidden = false - serverText.isHidden = false + bgDisplayContainer.isHidden = false // Show graphs based on settings updateGraphVisibility() - // Show/hide info table and stats based on user settings + // Show/hide info table based on user settings let isNightscoutEnabled = IsNightscoutEnabled() - if isNightscoutEnabled { - infoTableContainer.isHidden = Storage.shared.hideInfoTable.value - LoopStatusLabel.isHidden = false - PredictionLabel.isHidden = IsNotLooping - } else { - infoTableContainer.isHidden = true - LoopStatusLabel.isHidden = true - PredictionLabel.isHidden = true - } + infoTableContainer.isHidden = !isNightscoutEnabled || Storage.shared.hideInfoTable.value statsView.isHidden = !Storage.shared.showStats.value } From 63e830780cbbe84e827bad5eda425317c3f5a62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 12:40:34 +0200 Subject: [PATCH 05/14] 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. --- LoopFollow.xcodeproj/project.pbxproj | 8 + .../ViewControllers/BGDisplayView.swift | 14 +- .../ViewControllers/LineChartWrapper.swift | 15 ++ LoopFollow/ViewControllers/MainHomeView.swift | 70 +++++ .../ViewControllers/MainViewController.swift | 247 +++--------------- 5 files changed, 130 insertions(+), 224 deletions(-) create mode 100644 LoopFollow/ViewControllers/LineChartWrapper.swift create mode 100644 LoopFollow/ViewControllers/MainHomeView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 39607f8bd..e5fa48268 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -101,6 +101,8 @@ 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 */; }; + 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 */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; @@ -552,6 +554,8 @@ 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 = ""; }; + 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 = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; @@ -1709,6 +1713,8 @@ isa = PBXGroup; children = ( DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */, + DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */, + DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */, CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */, EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */, FC97881B2485969B00A7906C /* MainViewController.swift */, @@ -2228,6 +2234,8 @@ DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.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 */, diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift index 9a7382277..ae626fa47 100644 --- a/LoopFollow/ViewControllers/BGDisplayView.swift +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -8,7 +8,6 @@ struct BGDisplayView: View { @ObservedObject var bgText = Observable.shared.bgText @ObservedObject var bgTextColor = Observable.shared.bgTextColor @ObservedObject var bgStale = Observable.shared.bgStale - @ObservedObject var bg = Observable.shared.bg @ObservedObject var directionText = Observable.shared.directionText @ObservedObject var deltaText = Observable.shared.deltaText @ObservedObject var minAgoText = Observable.shared.minAgoText @@ -20,14 +19,6 @@ struct BGDisplayView: View { var onRefresh: (() -> Void)? - private var bgFontSize: CGFloat { - guard let bgValue = bg.value else { return 85 } - if bgValue <= globalVariables.minDisplayGlucose || bgValue >= globalVariables.maxDisplayGlucose { - return 65 - } - return 85 - } - var body: some View { ScrollView { VStack(spacing: 0) { @@ -35,7 +26,7 @@ struct BGDisplayView: View { .font(.system(size: 13)) Text(bgText.value) - .font(.system(size: bgFontSize, weight: .black)) + .font(.system(size: 85, weight: .black)) .foregroundColor(bgTextColor.value) .strikethrough( bgStale.value, @@ -43,6 +34,7 @@ struct BGDisplayView: View { color: bgStale.value ? .red : .clear ) .frame(maxWidth: .infinity) + .lineLimit(1) .minimumScaleFactor(0.5) HStack { @@ -51,6 +43,8 @@ struct BGDisplayView: View { Text(deltaText.value) .font(.system(size: 32)) } + .lineLimit(1) + .minimumScaleFactor(0.5) Text(minAgoText.value) .font(.system(size: 17)) diff --git a/LoopFollow/ViewControllers/LineChartWrapper.swift b/LoopFollow/ViewControllers/LineChartWrapper.swift new file mode 100644 index 000000000..7eb66b5ea --- /dev/null +++ b/LoopFollow/ViewControllers/LineChartWrapper.swift @@ -0,0 +1,15 @@ +// LoopFollow +// LineChartWrapper.swift + +import Charts +import SwiftUI + +struct LineChartWrapper: UIViewRepresentable { + let chartView: LineChartView + + func makeUIView(context _: Context) -> LineChartView { + chartView + } + + func updateUIView(_: LineChartView, context _: Context) {} +} 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 ccf49c545..65b3fc44c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -28,11 +28,10 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio var BGChart: LineChartView! var BGChartFull: LineChartView! - var infoTableContainer: UIView! - var bgDisplayContainer: UIView! var statsDisplayModel = StatsDisplayModel() - var statsView: UIView! - var smallGraphHeightConstraint: NSLayoutConstraint! + + /// 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! @@ -133,68 +132,37 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio private func setupUI() { view.backgroundColor = .systemBackground - // --- Top section: BG display + info table (horizontal stack) --- - - bgDisplayContainer = UIView() - bgDisplayContainer.translatesAutoresizingMaskIntoConstraints = false - - infoTableContainer = UIView() - infoTableContainer.translatesAutoresizingMaskIntoConstraints = false - let tableWidthConstraint = infoTableContainer.widthAnchor.constraint(equalToConstant: 250) - tableWidthConstraint.priority = .defaultHigh - tableWidthConstraint.isActive = true - - let topStack = UIStackView(arrangedSubviews: [bgDisplayContainer, infoTableContainer]) - topStack.axis = .horizontal - topStack.spacing = 10 - topStack.translatesAutoresizingMaskIntoConstraints = false - topStack.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - - // --- Bottom section: charts + stats (vertical stack) --- - BGChart = LineChartView() BGChart.backgroundColor = .systemBackground - BGChart.setContentHuggingPriority(.defaultHigh, for: .horizontal) - BGChart.setContentHuggingPriority(.defaultHigh, for: .vertical) - BGChart.setContentCompressionResistancePriority(.defaultLow, for: .vertical) BGChartFull = LineChartView() BGChartFull.backgroundColor = .systemBackground - BGChartFull.autoresizesSubviews = false - BGChartFull.setContentCompressionResistancePriority(.required, for: .vertical) - smallGraphHeightConstraint = BGChartFull.heightAnchor.constraint(equalToConstant: 40) - smallGraphHeightConstraint.isActive = true - - // Stats view (SwiftUI hosted) - statsView = UIView() - statsView.setContentCompressionResistancePriority(.required, for: .vertical) - let statsHeightConstraint = statsView.heightAnchor.constraint(equalToConstant: 100) - statsHeightConstraint.isActive = true - setupStatsView() - - let bottomStack = UIStackView(arrangedSubviews: [BGChart, BGChartFull, statsView]) - bottomStack.axis = .vertical - bottomStack.spacing = 8 - bottomStack.setContentHuggingPriority(.defaultHigh, for: .horizontal) - bottomStack.setContentHuggingPriority(.required, for: .vertical) - bottomStack.translatesAutoresizingMaskIntoConstraints = false - - // --- Add to view and constrain --- - - view.addSubview(topStack) - view.addSubview(bottomStack) + 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) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) let safeArea = view.safeAreaLayoutGuide NSLayoutConstraint.activate([ - topStack.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 8), - topStack.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 8), - topStack.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -8), - - bottomStack.topAnchor.constraint(equalTo: topStack.bottomAnchor, constant: 8), - bottomStack.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 8), - bottomStack.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -8), - bottomStack.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -8), + 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() { @@ -210,20 +178,13 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() - infoManager = InfoManager() - setupInfoTableView() - - 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 BGChart.delegate = self BGChartFull.delegate = self @@ -252,7 +213,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio scheduleAllTasks() - setupBGDisplayView() NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) /// When an alarm is triggered, go to the snoozer tab @@ -282,13 +242,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } .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) - Storage.shared.useIFCC.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -296,13 +249,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } .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 @@ -317,20 +263,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } .store(in: &cancellables) - Storage.shared.graphTimeZoneEnabled.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateInfoTableTimeZone() - } - .store(in: &cancellables) - - Storage.shared.graphTimeZoneIdentifier.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateInfoTableTimeZone() - } - .store(in: &cancellables) - Storage.shared.speakBG.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -613,91 +545,10 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio if Observable.shared.chartSettingsChanged.value { updateBGGraphSettings() - - smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) - view.layoutIfNeeded() - Observable.shared.chartSettingsChanged.value = false } } - private func setupBGDisplayView() { - let bgDisplayView = BGDisplayView(onRefresh: { [weak self] in - self?.refresh() - }) - let hosting = UIHostingController(rootView: bgDisplayView) - hosting.view.translatesAutoresizingMaskIntoConstraints = false - hosting.view.backgroundColor = .clear - - addChild(hosting) - bgDisplayContainer.addSubview(hosting.view) - NSLayoutConstraint.activate([ - hosting.view.topAnchor.constraint(equalTo: bgDisplayContainer.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: bgDisplayContainer.bottomAnchor), - hosting.view.leadingAnchor.constraint(equalTo: bgDisplayContainer.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: bgDisplayContainer.trailingAnchor), - ]) - hosting.didMove(toParent: self) - } - - private var infoTableHostingController: UIHostingController? - - private var timeZoneOverrideInfoValue: String? { - guard Storage.shared.graphTimeZoneEnabled.value, - let overrideTimeZone = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) - else { - return nil - } - - return overrideTimeZone.identifier - } - - private func setupInfoTableView() { - let infoTableView = InfoTableView( - infoManager: infoManager, - timeZoneOverride: timeZoneOverrideInfoValue - ) - let hosting = UIHostingController(rootView: infoTableView) - hosting.view.translatesAutoresizingMaskIntoConstraints = false - hosting.view.backgroundColor = .clear - infoTableHostingController = hosting - - addChild(hosting) - infoTableContainer.addSubview(hosting.view) - NSLayoutConstraint.activate([ - hosting.view.topAnchor.constraint(equalTo: infoTableContainer.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: infoTableContainer.bottomAnchor), - hosting.view.leadingAnchor.constraint(equalTo: infoTableContainer.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: infoTableContainer.trailingAnchor), - ]) - hosting.didMove(toParent: self) - - infoTableContainer.addBorder(toSide: .Left, withColor: UIColor.darkGray.cgColor, andThickness: 2) - } - - private func updateInfoTableTimeZone() { - infoTableHostingController?.rootView.timeZoneOverride = timeZoneOverrideInfoValue - } - - private func setupStatsView() { - let statsDisplayView = StatsDisplayView(model: statsDisplayModel) { [weak self] in - self?.statsViewTapped() - } - let hosting = UIHostingController(rootView: statsDisplayView) - hosting.view.translatesAutoresizingMaskIntoConstraints = false - hosting.view.backgroundColor = .clear - - addChild(hosting) - statsView.addSubview(hosting.view) - NSLayoutConstraint.activate([ - hosting.view.topAnchor.constraint(equalTo: statsView.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: statsView.bottomAnchor), - hosting.view.leadingAnchor.constraint(equalTo: statsView.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: statsView.trailingAnchor), - ]) - hosting.didMove(toParent: self) - } - @objc func appMovedToBackground() { // Allow screen to turn off UIApplication.shared.isIdleTimerDisabled = false @@ -908,10 +759,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio return } - let isHidden = !IsNightscoutEnabled() - - infoTableContainer.isHidden = isHidden || Storage.shared.hideInfoTable.value - + // Info table visibility is handled reactively by MainHomeView. updateNightscoutTabState() } @@ -1290,15 +1138,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio 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 @@ -1306,38 +1145,18 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } private func hideAllDataUI() { - // Hide graphs - BGChart.isHidden = true - BGChartFull.isHidden = true - - // Hide BG display and info table - bgDisplayContainer.isHidden = true - infoTableContainer.isHidden = true - statsView.isHidden = true + mainContentView?.isHidden = true } private func showAllDataUI() { - bgDisplayContainer.isHidden = false - - // Show graphs based on settings - updateGraphVisibility() - - // Show/hide info table based on user settings - let isNightscoutEnabled = IsNightscoutEnabled() - infoTableContainer.isHidden = !isNightscoutEnabled || Storage.shared.hideInfoTable.value - - 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 } } From 7ed09ca8e3a591962433246eef3995dbf42e82b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 12:52:19 +0200 Subject: [PATCH 06/14] 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 --- LoopFollow.xcodeproj/project.pbxproj | 4 -- LoopFollow/Application/AppDelegate.swift | 3 +- LoopFollow/Application/LoopFollowApp.swift | 3 -- LoopFollow/Application/MainTabView.swift | 1 - .../Controllers/Nightscout/DeviceStatus.swift | 3 +- .../Nightscout/DeviceStatusLoop.swift | 3 +- LoopFollow/Extensions/UIViewExtension.swift | 24 --------- LoopFollow/Remote/RemoteContentView.swift | 1 - LoopFollow/Task/MinAgoTask.swift | 1 - .../ViewControllers/MainViewController.swift | 51 ++----------------- LoopFollow/ViewControllers/MoreMenuView.swift | 3 -- .../NightScoutViewController.swift | 18 ------- 12 files changed, 9 insertions(+), 106 deletions(-) delete mode 100644 LoopFollow/Extensions/UIViewExtension.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index e5fa48268..3b12cc9dd 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -238,7 +238,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 */; }; @@ -693,7 +692,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 = ""; }; @@ -1172,7 +1170,6 @@ isa = PBXGroup; children = ( DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */, - DDCF979324C0D380002C9752 /* UIViewExtension.swift */, DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */, DD0C0C632C45A59400DBADDF /* HKUnit+Extensions.swift */, DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */, @@ -2216,7 +2213,6 @@ DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.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 */, diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 32ee9d3a9..38e14c013 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -10,6 +10,7 @@ import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() + private let speechSynthesizer = AVSpeechSynthesizer() func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") @@ -128,7 +129,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { 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) - AVSpeechSynthesizer().speak(utterance) + speechSynthesizer.speak(utterance) completionHandler(true) } else { completionHandler(false) diff --git a/LoopFollow/Application/LoopFollowApp.swift b/LoopFollow/Application/LoopFollowApp.swift index bd077d477..05956fcf3 100644 --- a/LoopFollow/Application/LoopFollowApp.swift +++ b/LoopFollow/Application/LoopFollowApp.swift @@ -1,15 +1,12 @@ // LoopFollow // LoopFollowApp.swift -import AVFoundation import SwiftUI @main struct LoopFollowApp: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate - private let synthesizer = AVSpeechSynthesizer() - var body: some Scene { WindowGroup { MainTabView() diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index a31675d98..bd04512bf 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -1,7 +1,6 @@ // LoopFollow // MainTabView.swift -import Combine import SwiftUI struct MainTabView: View { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 9314fa1d4..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() { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 26ac32dde..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]) { 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/Remote/RemoteContentView.swift b/LoopFollow/Remote/RemoteContentView.swift index 3043081a4..d5694801e 100644 --- a/LoopFollow/Remote/RemoteContentView.swift +++ b/LoopFollow/Remote/RemoteContentView.swift @@ -1,7 +1,6 @@ // LoopFollow // RemoteContentView.swift -import Combine import SwiftUI struct RemoteContentView: View { diff --git a/LoopFollow/Task/MinAgoTask.swift b/LoopFollow/Task/MinAgoTask.swift index b1166589a..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) { diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 65b3fc44c..b469c2708 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -455,12 +455,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } } - /// Static method kept for backward compatibility — with SwiftUI TabView, - /// tab rebuilding is handled reactively by MainTabView. - static func rebuildTabsIfNeeded() { - // No-op: SwiftUI TabView observes Storage position changes directly - } - @objc private func navigateOnLAForeground() { let orderedItems = Storage.shared.orderedTabBarItems() if Observable.shared.currentAlarm.value != nil, @@ -540,7 +534,8 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio currentIage = nil } - override func viewWillAppear(_: Bool) { + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value if Observable.shared.chartSettingsChanged.value { @@ -554,11 +549,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio 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() @@ -727,7 +718,8 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } } - @objc override func viewDidAppear(_: Bool) { + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) showHideNSDetails() #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.startFromCurrentState() @@ -741,26 +733,8 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio 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 - } - // Info table visibility is handled reactively by MainHomeView. - updateNightscoutTabState() } func updateBadge(val: Int) { @@ -807,25 +781,10 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio // 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": "-", "": "-"] diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 034c70a46..63d0ef1f6 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -118,9 +118,6 @@ struct MoreMenuView: View { } .navigationDestination(isPresented: $showSettingsView) { SettingsMenuView() - .onDisappear { - MainViewController.rebuildTabsIfNeeded() - } } .navigationDestination(isPresented: $showAlarmsView) { AlarmsContainerView() diff --git a/LoopFollow/ViewControllers/NightScoutViewController.swift b/LoopFollow/ViewControllers/NightScoutViewController.swift index e8d86d281..f4b71f074 100644 --- a/LoopFollow/ViewControllers/NightScoutViewController.swift +++ b/LoopFollow/ViewControllers/NightScoutViewController.swift @@ -37,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 @@ -73,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]) From d5ed19ca51d16db0105b862cbfa1c83b1adf0822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 12:56:28 +0200 Subject: [PATCH 07/14] 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. --- .../Helpers/BackgroundRefreshManager.swift | 32 +------------------ LoopFollow/Settings/SettingsMenuView.swift | 32 +------------------ LoopFollow/Treatments/TreatmentsView.swift | 20 +----------- .../ViewControllers/MainViewController.swift | 5 +++ 4 files changed, 8 insertions(+), 81 deletions(-) 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/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 80ae07f16..fb99a942a 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -222,37 +222,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 } } diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index da6d97182..8c9bf0394 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 } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index b469c2708..91f314789 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -24,6 +24,10 @@ private struct APNSCredentialSnapshot: Equatable { } class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate { + /// Singleton reference set during viewDidLoad. Used by code that needs + /// to reach MainViewController without walking the view hierarchy. + private(set) weak static var shared: MainViewController? + var isPresentedAsModal: Bool = false var BGChart: LineChartView! @@ -167,6 +171,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio override func viewDidLoad() { super.viewDidLoad() + MainViewController.shared = self setupUI() From 561375baedd60c835364296fd7469beec98c4535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 19 Apr 2026 10:27:04 +0200 Subject: [PATCH 08/14] 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. --- LoopFollow/Application/MainTabView.swift | 2 +- LoopFollow/Treatments/TreatmentsView.swift | 21 +------------------ LoopFollow/ViewControllers/MoreMenuView.swift | 2 +- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index bd04512bf..dbb2699b9 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -55,7 +55,7 @@ struct MainTabView: View { TreatmentsView() case .stats: NavigationStack { - AggregatedStatsContentView(mainViewController: nil) + AggregatedStatsContentView(mainViewController: MainViewController.shared) } } } diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index 8c9bf0394..f1b1c7595 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -1399,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/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 63d0ef1f6..6321107f1 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -135,7 +135,7 @@ struct MoreMenuView: View { TreatmentsView() } .navigationDestination(isPresented: $showStatsView) { - AggregatedStatsContentView(mainViewController: nil) + AggregatedStatsContentView(mainViewController: MainViewController.shared) } .navigationDestination(isPresented: $showHomeView) { HomeContentView(isModal: true) From 69434c974d3b490e3743cdceac68e0ebe0841304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 19 Apr 2026 10:51:12 +0200 Subject: [PATCH 09/14] 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. --- LoopFollow/InfoTable/InfoTableView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift index eaed97ada..218b3fe70 100644 --- a/LoopFollow/InfoTable/InfoTableView.swift +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -27,7 +27,7 @@ struct InfoTableView: View { Text(value) .foregroundStyle(.primary) } - .font(.subheadline) + .font(.system(size: 17)) .frame(height: 21) .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) } From 122c49e3150231e63c8d899dec8c3bfd26a5d3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 21 Apr 2026 18:18:12 +0200 Subject: [PATCH 10/14] Fix Share Logs sheet rendering blank Present UIActivityViewController via UIApplication.topMost instead of wrapping it in a SwiftUI .sheet, which rendered an empty view. --- LoopFollow/ViewControllers/MoreMenuView.swift | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 6321107f1..ab44cb9c0 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -2,12 +2,11 @@ // MoreMenuView.swift import SwiftUI +import UIKit struct MoreMenuView: View { @State private var latestVersion: String? @State private var versionTint: Color = .secondary - @State private var showShareSheet = false - @State private var shareFiles: [URL] = [] @State private var alertTitle = "" @State private var alertMessage = "" @State private var showAlert = false @@ -108,9 +107,6 @@ struct MoreMenuView: View { .task { await fetchVersionInfo() } - .sheet(isPresented: $showShareSheet) { - ActivityView(activityItems: shareFiles) - } .alert(alertTitle, isPresented: $showAlert) { Button("OK", role: .cancel) {} } message: { @@ -184,8 +180,8 @@ struct MoreMenuView: View { showAlert = true return } - shareFiles = files - showShareSheet = true + let avc = UIActivityViewController(activityItems: files, applicationActivities: nil) + UIApplication.shared.topMost?.present(avc, animated: true) } private func fetchVersionInfo() async { @@ -200,15 +196,3 @@ struct MoreMenuView: View { : .secondary } } - -// MARK: - UIActivityViewController wrapper - -struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context _: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - - func updateUIViewController(_: UIActivityViewController, context _: Context) {} -} From db546b8b9f9098d8747fb5b95160721d485b18cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 28 Apr 2026 18:49:53 +0200 Subject: [PATCH 11/14] 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. --- LoopFollow/Helpers/Views/NavigationRow.swift | 11 +- LoopFollow/Settings/SettingsMenuView.swift | 182 ++++++++---------- LoopFollow/Storage/Observable.swift | 2 - LoopFollow/ViewControllers/MoreMenuView.swift | 7 +- 4 files changed, 80 insertions(+), 122 deletions(-) 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/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index e587d2599..19069bf12 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -8,7 +8,6 @@ struct SettingsMenuView: View { // MARK: - Observed Objects @ObservedObject private var nightscoutURL = Storage.shared.url - @ObservedObject private var settingsPath = Observable.shared.settingsPath // MARK: – Local state @@ -21,112 +20,83 @@ struct SettingsMenuView: View { // 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: "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) - } + NavigationRow(title: "Tabs", + icon: "rectangle.3.group", + value: SettingsRoute.tabSettings) + } - #if !targetEnvironment(macCatalyst) - NavigationRow(title: "Live Activity", - icon: "dot.radiowaves.left.and.right") - { - settingsPath.value.append(Sheet.liveActivity) - } - #endif - - if !nightscoutURL.value.isEmpty { - NavigationRow(title: "Remote", - icon: "antenna.radiowaves.left.and.right") - { - settingsPath.value.append(Sheet.remote) - } - } + 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("Alarms") { - NavigationRow(title: "Alarms", - icon: "bell.badge") - { - settingsPath.value.append(Sheet.alarmSettings) - } - } + Section("Alarms") { + NavigationRow(title: "Alarms", + icon: "bell.badge", + value: SettingsRoute.alarmSettings) + } - Section("Integrations") { - NavigationRow(title: "Calendar", - icon: "calendar") - { - settingsPath.value.append(Sheet.calendar) - } + Section("Integrations") { + NavigationRow(title: "Calendar", + icon: "calendar", + value: SettingsRoute.calendar) - NavigationRow(title: "Contact", - icon: "person.circle") - { - settingsPath.value.append(Sheet.contact) - } - } + NavigationRow(title: "Contact", + icon: "person.circle", + value: SettingsRoute.contact) + } - Section("Advanced Settings") { - NavigationRow(title: "Advanced", - icon: "exclamationmark.shield") - { - settingsPath.value.append(Sheet.advanced) - } - } + Section("Advanced Settings") { + NavigationRow(title: "Advanced", + icon: "exclamationmark.shield", + value: SettingsRoute.advanced) } - .navigationTitle("Settings") - .navigationBarTitleDisplayMode(.large) - .navigationDestination(for: Sheet.self) { $0.destination } - .toolbar { - if let onBack { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: onBack) { - Image(systemName: "chevron.left") - } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.large) + .toolbar { + if let onBack { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: onBack) { + Image(systemName: "chevron.left") } } } @@ -149,23 +119,20 @@ struct SettingsMenuView: View { .pickerStyle(.segmented) 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 nightscout, dexcom case backgroundRefresh case general, graph @@ -187,6 +154,7 @@ private enum Sheet: Hashable, Identifiable { @ViewBuilder var destination: some View { switch self { + case .settings: SettingsMenuView() case .nightscout: NightscoutSettingsView(viewModel: .init()) case .dexcom: DexcomSettingsView(viewModel: .init()) case .backgroundRefresh: BackgroundRefreshSettingsView(viewModel: .init()) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 7ea134926..4128918db 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -44,8 +44,6 @@ 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: "") diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index ab44cb9c0..6ed168be7 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -10,7 +10,6 @@ struct MoreMenuView: View { @State private var alertTitle = "" @State private var alertMessage = "" @State private var showAlert = false - @State private var showSettingsView = false @State private var showAlarmsView = false @State private var showRemoteView = false @State private var showNightscoutView = false @@ -24,7 +23,7 @@ struct MoreMenuView: View { List { // Settings Section { - Button { showSettingsView = true } label: { + NavigationLink(value: SettingsRoute.settings) { Label("Settings", systemImage: "gearshape") .foregroundStyle(.primary) } @@ -112,9 +111,7 @@ struct MoreMenuView: View { } message: { Text(alertMessage) } - .navigationDestination(isPresented: $showSettingsView) { - SettingsMenuView() - } + .navigationDestination(for: SettingsRoute.self) { $0.destination } .navigationDestination(isPresented: $showAlarmsView) { AlarmsContainerView() } From 1a6f75fc46af6d65fcfe464f1ffdde632e51c22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 16:02:20 +0200 Subject: [PATCH 12/14] Harden post-storyboard migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- LoopFollow/Alarm/AlarmsContainerView.swift | 9 - LoopFollow/Application/AppDelegate.swift | 50 +---- LoopFollow/Application/LoopFollowApp.swift | 8 + LoopFollow/Application/MainTabView.swift | 3 +- .../BackgroundRefreshSettingsView.swift | 24 ++- LoopFollow/Controllers/AlarmSound.swift | 6 +- LoopFollow/Helpers/AppearanceMode.swift | 4 - LoopFollow/Info.plist | 14 -- .../InfoDisplaySettingsView.swift | 52 +++--- .../Nightscout/NightscoutSettingsView.swift | 18 +- .../Settings/AdvancedSettingsView.swift | 28 ++- .../Settings/CalendarSettingsView.swift | 86 +++++---- LoopFollow/Settings/ContactSettingsView.swift | 158 ++++++++-------- LoopFollow/Settings/DexcomSettingsView.swift | 48 +++-- LoopFollow/Settings/GeneralSettingsView.swift | 158 ++++++++-------- LoopFollow/Settings/GraphSettingsView.swift | 174 +++++++++--------- LoopFollow/Settings/HomeContentView.swift | 9 +- .../ImportExportSettingsView.swift | 90 +++++---- LoopFollow/Settings/SettingsMenuView.swift | 30 +-- .../Storage/Framework/ObservableValue.swift | 1 - .../ViewControllers/LineChartWrapper.swift | 6 +- .../ViewControllers/MainViewController.swift | 13 +- LoopFollow/ViewControllers/MoreMenuView.swift | 116 ++++++------ 23 files changed, 505 insertions(+), 600 deletions(-) diff --git a/LoopFollow/Alarm/AlarmsContainerView.swift b/LoopFollow/Alarm/AlarmsContainerView.swift index 133bea1d5..dea4ce515 100644 --- a/LoopFollow/Alarm/AlarmsContainerView.swift +++ b/LoopFollow/Alarm/AlarmsContainerView.swift @@ -4,19 +4,10 @@ import SwiftUI struct AlarmsContainerView: View { - var onBack: (() -> Void)? - 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() diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 38e14c013..5ef8a8f4e 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -2,13 +2,11 @@ // AppDelegate.swift import AVFoundation -import CoreData import EventKit import UIKit import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() private let speechSynthesizer = AVSpeechSynthesizer() @@ -136,54 +134,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - // 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)") - } - } - } - func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == "OPEN_APP_ACTION" { - // Switch to Home tab + // Dismiss any presented modal/sheet so the user actually sees Home + UIApplication.shared.topMost?.dismiss(animated: true) Observable.shared.selectedTabIndex.value = 0 } diff --git a/LoopFollow/Application/LoopFollowApp.swift b/LoopFollow/Application/LoopFollowApp.swift index ee5e2f0fc..4e65e3c29 100644 --- a/LoopFollow/Application/LoopFollowApp.swift +++ b/LoopFollow/Application/LoopFollowApp.swift @@ -7,6 +7,14 @@ import SwiftUI struct LoopFollowApp: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + init() { + // Force-load MainViewController.shared so its viewDidLoad runs at launch. + // All app-lifecycle work (Combine sinks, observers, scheduleAllTasks, + // migrations) lives there and must run regardless of whether the Home + // tab is rendered (it isn't, if the user moved Home to the Menu). + MainViewController.shared.loadViewIfNeeded() + } + var body: some Scene { WindowGroup { MainTabView() diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index dbb2699b9..3ce37fd50 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -5,6 +5,7 @@ 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 @@ -35,7 +36,7 @@ struct MainTabView: View { } .tag(4) } - .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .preferredColorScheme(appearanceMode.value.colorScheme) } @ViewBuilder 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 ba131bc4f..e3a5ce076 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -325,7 +325,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/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/Info.plist b/LoopFollow/Info.plist index dd64ccb3b..898d09616 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -78,20 +78,6 @@ UILaunchStoryboardName LaunchScreen -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/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index 7c08e756f..9444bc12d 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -7,16 +7,14 @@ struct NightscoutSettingsView: View { @ObservedObject var viewModel: NightscoutSettingsViewModel var body: some View { - NavigationView { - Form { - urlSection - tokenSection - statusSection - importSection - } - .onDisappear { - viewModel.dismiss() - } + Form { + urlSection + tokenSection + statusSection + importSection + } + .onDisappear { + viewModel.dismiss() } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Nightscout Settings", displayMode: .inline) 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/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index c93b31855..98e47bfef 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -7,35 +7,33 @@ struct DexcomSettingsView: View { @ObservedObject var viewModel: DexcomSettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("Dexcom Settings")) { - HStack { - Text("User Name") - TextField("Enter User Name", text: $viewModel.userName) - .autocapitalization(.none) - .disableAutocorrection(true) - .multilineTextAlignment(.trailing) - } - - HStack { - Text("Password") - TogglableSecureInput( - placeholder: "Enter Password", - text: $viewModel.password, - style: .singleLine - ) - } + Form { + Section(header: Text("Dexcom Settings")) { + HStack { + Text("User Name") + TextField("Enter User Name", text: $viewModel.userName) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } - Picker("Server", selection: $viewModel.server) { - Text("US").tag("US") - Text("NON-US").tag("NON-US") - } - .pickerStyle(SegmentedPickerStyle()) + HStack { + Text("Password") + TogglableSecureInput( + placeholder: "Enter Password", + text: $viewModel.password, + style: .singleLine + ) } - importSection + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("NON-US").tag("NON-US") + } + .pickerStyle(SegmentedPickerStyle()) } + + importSection } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Dexcom Settings", displayMode: .inline) diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 93b7c8f4f..c42a5e60e 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -30,105 +30,103 @@ struct GeneralSettingsView: View { @ObservedObject var speakHighBGLimit = Storage.shared.speakHighBGLimit 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("Use IFCC A1C", isOn: $useIFCC.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("Use IFCC A1C", isOn: $useIFCC.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 + ) + } + + if speakProactiveLowBG.value { + BGPicker( + title: "Fast Drop Delta", + range: 3 ... 20, + value: $speakFastDropDelta.value + ) + } - Toggle("High", isOn: $speakHighBG.value.animation()) + Toggle("High", isOn: $speakHighBG.value.animation()) - if speakHighBG.value { - BGPicker( - title: "High BG Limit", - range: 140 ... 300, - value: $speakHighBGLimit.value - ) - } + if speakHighBG.value { + BGPicker( + title: "High BG Limit", + range: 140 ... 300, + value: $speakHighBGLimit.value + ) } } } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 4ebc1896a..5b8ba5859 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -25,110 +25,108 @@ 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") { + // ── 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() } + } + + // ── 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" } - ) - } - } + // ── 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" } + ) - // ── 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() } - } + BGPicker( + title: "Min BG Scale", + range: 40 ... 400, + value: $minBGScale.value + ) + .onChange(of: minBGScale.value) { _ in markDirty() } } + } - // ── Target lines ───────────────────────────────────────────── - Section("Target Lines") { - BGPicker(title: "Low BG Line", - range: 40 ... 120, - value: $lowLine.value) - .onChange(of: lowLine.value) { _ in markDirty() } - - BGPicker(title: "High BG Line", - range: 120 ... 400, - value: $highLine.value) - .onChange(of: highLine.value) { _ in markDirty() } - } + // ── Target lines ───────────────────────────────────────────── + Section("Target Lines") { + BGPicker(title: "Low BG Line", + range: 40 ... 120, + value: $lowLine.value) + .onChange(of: lowLine.value) { _ in markDirty() } + + BGPicker(title: "High BG Line", + range: 120 ... 400, + value: $highLine.value) + .onChange(of: highLine.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 0527aedd7..24d4191f3 100644 --- a/LoopFollow/Settings/HomeContentView.swift +++ b/LoopFollow/Settings/HomeContentView.swift @@ -4,8 +4,8 @@ import SwiftUI 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. +/// A SwiftUI wrapper around the shared `MainViewController`. Used both as +/// the Home tab's content and as a pushed/modal view from the Menu. struct HomeContentView: UIViewControllerRepresentable { let isModal: Bool @@ -14,9 +14,8 @@ struct HomeContentView: UIViewControllerRepresentable { } func makeUIViewController(context _: Context) -> UIViewController { - let mainVC = MainViewController() + let mainVC = MainViewController.shared mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle - mainVC.isPresentedAsModal = isModal return mainVC } @@ -31,7 +30,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 19069bf12..3a48e5bff 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -5,20 +5,8 @@ import SwiftUI import UIKit struct SettingsMenuView: View { - // MARK: - Observed Objects - @ObservedObject private var nightscoutURL = Storage.shared.url - // MARK: – Local state - - var onBack: (() -> Void)? - - // MARK: – Observed objects - - @ObservedObject private var url = Storage.shared.url - - // MARK: – Body - var body: some View { List { dataSection @@ -92,15 +80,6 @@ struct SettingsMenuView: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.large) - .toolbar { - if let onBack { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: onBack) { - Image(systemName: "chevron.left") - } - } - } - } } // MARK: – Section builders @@ -206,7 +185,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/Storage/Framework/ObservableValue.swift b/LoopFollow/Storage/Framework/ObservableValue.swift index c03448f1e..e690b4ac2 100644 --- a/LoopFollow/Storage/Framework/ObservableValue.swift +++ b/LoopFollow/Storage/Framework/ObservableValue.swift @@ -14,7 +14,6 @@ class ObservableValue: ObservableObject { } func set(_ newValue: T) { - print("Setting new value: \(newValue)") DispatchQueue.main.async { self.value = newValue } diff --git a/LoopFollow/ViewControllers/LineChartWrapper.swift b/LoopFollow/ViewControllers/LineChartWrapper.swift index 7eb66b5ea..51a896b7f 100644 --- a/LoopFollow/ViewControllers/LineChartWrapper.swift +++ b/LoopFollow/ViewControllers/LineChartWrapper.swift @@ -11,5 +11,9 @@ struct LineChartWrapper: UIViewRepresentable { chartView } - func updateUIView(_: LineChartView, context _: Context) {} + func updateUIView(_ uiView: LineChartView, context _: Context) { + // The chart's data is mutated externally by MainViewController; this + // hook ensures any SwiftUI-driven re-render still flushes the chart. + uiView.notifyDataSetChanged() + } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 475c6f9b7..6e03ced5c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -24,11 +24,11 @@ private struct APNSCredentialSnapshot: Equatable { } class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate { - /// Singleton reference set during viewDidLoad. Used by code that needs - /// to reach MainViewController without walking the view hierarchy. - private(set) weak static var shared: MainViewController? - - var isPresentedAsModal: Bool = false + /// Single shared instance. Constructed at app launch via + /// `LoopFollowApp.init()` so that all lifecycle setup in `viewDidLoad` + /// (Combine sinks, observers, scheduleAllTasks, migrations) runs + /// regardless of where the Home tab sits in the user's tab bar order. + static let shared = MainViewController() var BGChart: LineChartView! var BGChartFull: LineChartView! @@ -171,7 +171,6 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio override func viewDidLoad() { super.viewDidLoad() - MainViewController.shared = self setupUI() @@ -504,7 +503,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } deinit { - NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil) + NotificationCenter.default.removeObserver(self) } // Clean all timers and start new ones when refreshing diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 6ed168be7..7f1725dff 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -10,14 +10,7 @@ struct MoreMenuView: View { @State private var alertTitle = "" @State private var alertMessage = "" @State private var showAlert = false - @State private var showAlarmsView = false - @State private var showRemoteView = false - @State private var showNightscoutView = false - @State private var showSnoozerView = false - @State private var showTreatmentsView = false - @State private var showStatsView = false - @State private var showHomeView = false - @State private var showLogView = false + @State private var currentVersion: String = AppVersionManager().version() var body: some View { List { @@ -31,17 +24,27 @@ struct MoreMenuView: View { // Features Section("Features") { + let tabs = Storage.shared.orderedTabBarItems() ForEach(TabItem.featureOrder) { item in - Button { openItem(item) } label: { - Label(item.displayName, systemImage: item.icon) - .foregroundStyle(.primary) + if let tabIndex = tabs.firstIndex(of: item) { + Button { + Observable.shared.selectedTabIndex.value = tabIndex + } label: { + Label(item.displayName, systemImage: item.icon) + .foregroundStyle(.primary) + } + } else { + NavigationLink(value: MenuRoute(item)) { + Label(item.displayName, systemImage: item.icon) + .foregroundStyle(.primary) + } } } } // Logging Section("Logging") { - Button { showLogView = true } label: { + NavigationLink(value: MenuRoute.log) { Label("View Log", systemImage: "doc.text.magnifyingglass") .foregroundStyle(.primary) } @@ -84,7 +87,7 @@ struct MoreMenuView: View { // Build Information Section("Build Information") { - buildInfoRow(title: "Version", value: AppVersionManager().version(), color: versionTint) + buildInfoRow(title: "Version", value: currentVersion, color: versionTint) buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary) let build = BuildDetails.default @@ -112,30 +115,7 @@ struct MoreMenuView: View { Text(alertMessage) } .navigationDestination(for: SettingsRoute.self) { $0.destination } - .navigationDestination(isPresented: $showAlarmsView) { - AlarmsContainerView() - } - .navigationDestination(isPresented: $showRemoteView) { - RemoteContentView() - } - .navigationDestination(isPresented: $showNightscoutView) { - NightscoutContentView() - } - .navigationDestination(isPresented: $showSnoozerView) { - SnoozerView() - } - .navigationDestination(isPresented: $showTreatmentsView) { - TreatmentsView() - } - .navigationDestination(isPresented: $showStatsView) { - AggregatedStatsContentView(mainViewController: MainViewController.shared) - } - .navigationDestination(isPresented: $showHomeView) { - HomeContentView(isModal: true) - } - .navigationDestination(isPresented: $showLogView) { - LogView() - } + .navigationDestination(for: MenuRoute.self) { $0.destination } } // MARK: - Helpers @@ -149,26 +129,6 @@ struct MoreMenuView: View { } } - private func openItem(_ item: TabItem) { - // Check if the item is in the tab bar — if so, switch to it - let orderedItems = Storage.shared.orderedTabBarItems() - if let index = orderedItems.firstIndex(of: item) { - Observable.shared.selectedTabIndex.value = index - return - } - - // Otherwise push it onto the navigation stack - switch item { - case .home: showHomeView = true - case .alarms: showAlarmsView = true - case .remote: showRemoteView = true - case .nightscout: showNightscoutView = true - case .snoozer: showSnoozerView = true - case .treatments: showTreatmentsView = true - case .stats: showStatsView = true - } - } - private func shareLogs() { let files = LogManager.shared.logFilesForTodayAndYesterday() guard !files.isEmpty else { @@ -186,10 +146,48 @@ struct MoreMenuView: View { let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync() latestVersion = latest ?? "Unknown" - let current = mgr.version() versionTint = blacklisted ? .red : newer ? .orange - : latest == current ? .green + : latest == currentVersion ? .green : .secondary } } + +// 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() + case .remote: RemoteContentView() + case .nightscout: NightscoutContentView() + case .snoozer: SnoozerView() + case .treatments: TreatmentsView() + case .stats: AggregatedStatsContentView(mainViewController: MainViewController.shared) + case .log: LogView() + } + } +} From b26943bef17c065aec8c4f3863aedf5190aaee1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 16:07:56 +0200 Subject: [PATCH 13/14] 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. --- LoopFollow/ViewControllers/MoreMenuView.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 7f1725dff..cf0fb6db3 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -18,7 +18,6 @@ struct MoreMenuView: View { Section { NavigationLink(value: SettingsRoute.settings) { Label("Settings", systemImage: "gearshape") - .foregroundStyle(.primary) } } @@ -31,12 +30,12 @@ struct MoreMenuView: View { Observable.shared.selectedTabIndex.value = tabIndex } label: { Label(item.displayName, systemImage: item.icon) - .foregroundStyle(.primary) } + .buttonStyle(.plain) + .contentShape(Rectangle()) } else { NavigationLink(value: MenuRoute(item)) { Label(item.displayName, systemImage: item.icon) - .foregroundStyle(.primary) } } } @@ -46,13 +45,13 @@ struct MoreMenuView: View { Section("Logging") { NavigationLink(value: MenuRoute.log) { Label("View Log", systemImage: "doc.text.magnifyingglass") - .foregroundStyle(.primary) } Button { shareLogs() } label: { Label("Share Logs", systemImage: "square.and.arrow.up") - .foregroundStyle(.primary) } + .buttonStyle(.plain) + .contentShape(Rectangle()) } // Support & Community From 30339e4e50b66d04456e64ac9fa767093f898c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Apr 2026 18:27:45 +0200 Subject: [PATCH 14/14] Revert MainViewController singleton bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- LoopFollow/Application/LoopFollowApp.swift | 8 -------- LoopFollow/Settings/HomeContentView.swift | 6 +++--- LoopFollow/ViewControllers/MainViewController.swift | 9 ++++----- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/LoopFollow/Application/LoopFollowApp.swift b/LoopFollow/Application/LoopFollowApp.swift index 4e65e3c29..ee5e2f0fc 100644 --- a/LoopFollow/Application/LoopFollowApp.swift +++ b/LoopFollow/Application/LoopFollowApp.swift @@ -7,14 +7,6 @@ import SwiftUI struct LoopFollowApp: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate - init() { - // Force-load MainViewController.shared so its viewDidLoad runs at launch. - // All app-lifecycle work (Combine sinks, observers, scheduleAllTasks, - // migrations) lives there and must run regardless of whether the Home - // tab is rendered (it isn't, if the user moved Home to the Menu). - MainViewController.shared.loadViewIfNeeded() - } - var body: some Scene { WindowGroup { MainTabView() diff --git a/LoopFollow/Settings/HomeContentView.swift b/LoopFollow/Settings/HomeContentView.swift index 24d4191f3..1a1b4c08b 100644 --- a/LoopFollow/Settings/HomeContentView.swift +++ b/LoopFollow/Settings/HomeContentView.swift @@ -4,8 +4,8 @@ import SwiftUI import UIKit -/// A SwiftUI wrapper around the shared `MainViewController`. Used both as -/// the Home tab's content and as a pushed/modal view from the Menu. +/// 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 { let isModal: Bool @@ -14,7 +14,7 @@ struct HomeContentView: UIViewControllerRepresentable { } func makeUIViewController(context _: Context) -> UIViewController { - let mainVC = MainViewController.shared + let mainVC = MainViewController() mainVC.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle return mainVC } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 572dbbad5..23c33a744 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -24,11 +24,9 @@ private struct APNSCredentialSnapshot: Equatable { } class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificationCenterDelegate { - /// Single shared instance. Constructed at app launch via - /// `LoopFollowApp.init()` so that all lifecycle setup in `viewDidLoad` - /// (Combine sinks, observers, scheduleAllTasks, migrations) runs - /// regardless of where the Home tab sits in the user's tab bar order. - static let shared = MainViewController() + /// Singleton reference set during viewDidLoad. Used by code that needs + /// to reach MainViewController without walking the view hierarchy. + private(set) weak static var shared: MainViewController? var BGChart: LineChartView! var BGChartFull: LineChartView! @@ -173,6 +171,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio override func viewDidLoad() { super.viewDidLoad() + MainViewController.shared = self setupUI()