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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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)) }