From 85977f8439d50c13a78104eed4c3b8d7b7ec4af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 13 Jun 2026 22:38:20 +0200 Subject: [PATCH 1/3] Add first-run onboarding guide Introduce a step-based onboarding wizard shown once on launch (skippable, with a prominent Skip for already-configured users): welcome, data source choice, Nightscout/Dexcom connection, units, and a few useful alarms seeded with sensible defaults. For Nightscout, add the option to create a read-only access token from the site's API secret. The secret is used only to authorize the request and is never stored. The token is derived locally from the created subject's id and the secret, since Nightscout's subjects list is served from a cache that does not reflect a freshly created subject right away. Replace the launch-time permission prompts with deferred requests so a fresh install is not fronted with them before onboarding: - notifications are requested when alarms are set up or first added - calendar is requested from the Calendar settings screen - Bluetooth is initialised only when a BLE background refresh mode is selected Welcome screen uses the LoopFollow mark with a coin-landing animation. --- LoopFollow.xcodeproj/project.pbxproj | 182 +++++++++++++---- LoopFollow/Alarm/AlarmListView.swift | 3 + LoopFollow/Application/AppDelegate.swift | 29 ++- LoopFollow/Application/MainTabView.swift | 30 ++- .../BackgroundRefresh/BT/BLEManager.swift | 7 + .../BackgroundRefreshSettingsViewModel.swift | 8 +- .../Controllers/BackgroundAlertManager.swift | 5 +- LoopFollow/Helpers/NightscoutUtils.swift | 120 ++++++++++++ .../Helpers/NotificationAuthorization.swift | 23 +++ LoopFollow/Onboarding/LoopFollowLogo.swift | 108 ++++++++++ .../Onboarding/OnboardingContainerView.swift | 136 +++++++++++++ LoopFollow/Onboarding/OnboardingStep.swift | 37 ++++ .../Onboarding/OnboardingStepHeader.swift | 32 +++ .../Onboarding/OnboardingViewModel.swift | 133 +++++++++++++ .../Onboarding/Steps/AlarmsStepView.swift | 132 +++++++++++++ .../Onboarding/Steps/CompletionStepView.swift | 60 ++++++ .../Steps/DataSourceChoiceStepView.swift | 77 ++++++++ .../Steps/DexcomConnectStepView.swift | 60 ++++++ .../Steps/NightscoutConnectStepView.swift | 185 ++++++++++++++++++ .../Onboarding/Steps/UnitsStepView.swift | 26 +++ .../Onboarding/Steps/WelcomeStepView.swift | 84 ++++++++ LoopFollow/Storage/Storage.swift | 1 + 22 files changed, 1413 insertions(+), 65 deletions(-) create mode 100644 LoopFollow/Helpers/NotificationAuthorization.swift create mode 100644 LoopFollow/Onboarding/LoopFollowLogo.swift create mode 100644 LoopFollow/Onboarding/OnboardingContainerView.swift create mode 100644 LoopFollow/Onboarding/OnboardingStep.swift create mode 100644 LoopFollow/Onboarding/OnboardingStepHeader.swift create mode 100644 LoopFollow/Onboarding/OnboardingViewModel.swift create mode 100644 LoopFollow/Onboarding/Steps/AlarmsStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/CompletionStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/UnitsStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/WelcomeStepView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 34edc6b79..fa70850bb 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,7 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 0E7F523C7C777DFDDFFCC2A8 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; + 2EADEE2EE5B46EF64ADF7348 /* NightscoutConnectStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; @@ -31,7 +33,10 @@ 37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; }; 37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 4B286B98268852C8ACF98E8E /* AlarmsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */; }; + 510B3641E5D3A7FBBAA713DD /* DataSourceChoiceStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17959684C89D6056922D9175 /* DataSourceChoiceStepView.swift */; }; 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; + 63AE109876D073B929203D51 /* OnboardingContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; @@ -64,7 +69,20 @@ 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; + 6953970EE39241506F90FF5B /* UnitsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A6FDE7D2E8EA86132A08B5 /* UnitsStepView.swift */; }; + 6D32AE9BDBC241941EAD3D53 /* OnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C214697E4CF30F11EF561 /* OnboardingViewModel.swift */; }; + 8B5DC3C7CB15EFE705F89952 /* CompletionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB18D2D8AC7FCABCA3287745 /* CompletionStepView.swift */; }; + 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; + A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; + A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F3 /* StatsDisplayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F5 /* StatsDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */; }; + AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */; }; + AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; }; ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; + BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */; }; + CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; }; + CCE18AC5C70DD24C4F07EEEF /* OnboardingStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69FA895A638B8FB7272E5BA8 /* OnboardingStep.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; @@ -88,10 +106,7 @@ 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 */; }; - DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */; }; DD0C0C722C4B000800DBADDF /* TrioNightscoutRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */; }; - AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */; }; - BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */; }; DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */; }; DD13BC752C3FD6210062313B /* InfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC742C3FD6200062313B /* InfoType.swift */; }; DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC762C3FD64E0062313B /* InfoData.swift */; }; @@ -101,11 +116,6 @@ 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 */; }; - CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; }; - DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */; }; - DD7A3B5F2F1E8DA000B4C6E1 /* LineChartWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */; }; - DD7A3B612F1E8DA600B4C6E1 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */; }; - EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */; }; DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; }; DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */; }; DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; @@ -115,7 +125,6 @@ DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; }; - AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; }; DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */; }; DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */; }; DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878122C7B750D0048F05C /* TempTargetView.swift */; }; @@ -142,6 +151,7 @@ DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */; }; DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */; }; DD4AFB6B2DB6BF2A00BB593F /* Binding+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */; }; + DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */; }; DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */; }; DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */; }; DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; }; @@ -157,6 +167,9 @@ DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.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 */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; DD7E19862ACDA59700DBD158 /* BGCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19852ACDA59700DBD158 /* BGCheck.swift */; }; @@ -255,7 +268,6 @@ DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; }; DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; }; DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; }; - A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; @@ -285,7 +297,12 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */; }; F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; + F373C36F29C2B30181012301 /* DexcomConnectStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4B395B305B3EF00F20C798 /* DexcomConnectStepView.swift */; }; + F38017DF9689DF1F7639653A /* NotificationAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A213D72341EF2C558030E88 /* NotificationAuthorization.swift */; }; + F957B82F8D80D5D762CDE068 /* LoopFollowLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */; }; + F9E4698DE8AE0D5FEB518AF1 /* OnboardingStepHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FDA7658658AD4AE5A3D14B /* OnboardingStepHeader.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* SpeakBG.swift */; }; @@ -293,12 +310,9 @@ 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 */; }; - A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; }; FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; }; FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; }; @@ -429,7 +443,6 @@ FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886A24898FD800A0279D /* ObservationToken.swift */; }; FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886C2489909D00A0279D /* AnyConvertible.swift */; }; FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886E2489A53800A0279D /* AppConstants.swift */; }; - 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD2A27C24C9D044009F7B7B /* Globals.swift */; }; FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */; }; FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; @@ -470,6 +483,9 @@ /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; + 0A213D72341EF2C558030E88 /* NotificationAuthorization.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationAuthorization.swift; sourceTree = ""; }; + 17959684C89D6056922D9175 /* DataSourceChoiceStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataSourceChoiceStepView.swift; sourceTree = ""; }; + 23FDA7658658AD4AE5A3D14B /* OnboardingStepHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingStepHeader.swift; sourceTree = ""; }; 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; @@ -490,6 +506,8 @@ 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessStore.swift; sourceTree = ""; }; 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessMarker.swift; sourceTree = ""; }; + 4D4B395B305B3EF00F20C798 /* DexcomConnectStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DexcomConnectStepView.swift; sourceTree = ""; }; + 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -521,9 +539,25 @@ 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; + 69FA895A638B8FB7272E5BA8 /* OnboardingStep.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingStep.swift; sourceTree = ""; }; + 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoopFollowLogo.swift; sourceTree = ""; }; + 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConnectStepView.swift; sourceTree = ""; }; + 96A6FDE7D2E8EA86132A08B5 /* UnitsStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UnitsStepView.swift; sourceTree = ""; }; + A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; + A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.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 = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; + AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopFollowApp.swift; sourceTree = ""; }; + AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = ""; }; + B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AlarmsStepView.swift; sourceTree = ""; }; B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; + BB18D2D8AC7FCABCA3287745 /* CompletionStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CompletionStepView.swift; sourceTree = ""; }; + BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; + CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = ""; }; + DB6C214697E4CF30F11EF561 /* OnboardingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -547,10 +581,7 @@ 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 = ""; }; - DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContentView.swift; sourceTree = ""; }; DD0C0C712C4B000800DBADDF /* TrioNightscoutRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioNightscoutRemoteView.swift; sourceTree = ""; }; - AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopFollowApp.swift; sourceTree = ""; }; - BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; DD12D4862E1705E6004E0112 /* AlarmsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmsContainerView.swift; sourceTree = ""; }; DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = ""; }; DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = ""; }; @@ -560,11 +591,6 @@ 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 = ""; }; - CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = ""; }; - DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGDisplayView.swift; sourceTree = ""; }; - DD7A3B5E2F1E8DA000B4C6E1 /* LineChartWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartWrapper.swift; sourceTree = ""; }; - DD7A3B602F1E8DA600B4C6E1 /* MainHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeView.swift; sourceTree = ""; }; - EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutContentView.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; @@ -574,7 +600,6 @@ DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = ""; }; DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = ""; }; - AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = ""; }; DD48780D2C7B74A40048F05C /* TrioRemoteControlViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlViewModel.swift; sourceTree = ""; }; DD48780F2C7B74BF0048F05C /* TrioRemoteControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControlView.swift; sourceTree = ""; }; DD4878122C7B750D0048F05C /* TempTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetView.swift; sourceTree = ""; }; @@ -601,6 +626,7 @@ DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmListView.swift; sourceTree = ""; }; DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UUID+Identifiable.swift"; sourceTree = ""; }; DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Optional.swift"; sourceTree = ""; }; + DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContentView.swift; sourceTree = ""; }; DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageUpdater.swift; sourceTree = ""; }; DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinCartridgeChange.swift; sourceTree = ""; }; DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = ""; }; @@ -616,6 +642,9 @@ DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.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 = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; DD7E19852ACDA59700DBD158 /* BGCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGCheck.swift; sourceTree = ""; }; @@ -716,7 +745,6 @@ DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = ""; }; DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = ""; }; DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = ""; }; - A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; @@ -746,16 +774,16 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingContainerView.swift; sourceTree = ""; }; E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutContentView.swift; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; FC16A97E249969E2003D6245 /* Graphs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Graphs.swift; sourceTree = ""; }; 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 = ""; }; @@ -885,7 +913,6 @@ FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = ""; }; FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = ""; }; - A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = ""; }; FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = ""; }; FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = ""; }; @@ -893,7 +920,6 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationToken.swift; sourceTree = ""; }; FCC6886C2489909D00A0279D /* AnyConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyConvertible.swift; sourceTree = ""; }; FCC6886E2489A53800A0279D /* AppConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; - BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; FCC688702489A57C00A0279D /* Loop Follow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Follow.entitlements"; sourceTree = ""; }; FCD2A27C24C9D044009F7B7B /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = carbBolusArrays.swift; sourceTree = ""; }; @@ -904,10 +930,50 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = ""; }; - 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; - 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; - DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = LoopFollowLAExtension; + sourceTree = ""; + }; + 65AC25F52ECFD5E800421360 /* Stats */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Stats; + sourceTree = ""; + }; + 65AC26702ED245DF00421360 /* Treatments */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Treatments; + sourceTree = ""; + }; + DDCC3AD72DDE1790006F1C10 /* Tests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Tests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1015,6 +1081,35 @@ path = Pods; sourceTree = ""; }; + BC3E3248623BA3A1C097F03A /* Steps */ = { + isa = PBXGroup; + children = ( + 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */, + 17959684C89D6056922D9175 /* DataSourceChoiceStepView.swift */, + 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */, + 4D4B395B305B3EF00F20C798 /* DexcomConnectStepView.swift */, + 96A6FDE7D2E8EA86132A08B5 /* UnitsStepView.swift */, + B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */, + BB18D2D8AC7FCABCA3287745 /* CompletionStepView.swift */, + ); + name = Steps; + path = Steps; + sourceTree = ""; + }; + D58FD15DB5B78FC38B3864F1 /* Onboarding */ = { + isa = PBXGroup; + children = ( + BC3E3248623BA3A1C097F03A /* Steps */, + 69FA895A638B8FB7272E5BA8 /* OnboardingStep.swift */, + DB6C214697E4CF30F11EF561 /* OnboardingViewModel.swift */, + E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */, + 23FDA7658658AD4AE5A3D14B /* OnboardingStepHeader.swift */, + 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */, + ); + name = Onboarding; + path = Onboarding; + sourceTree = ""; + }; DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */ = { isa = PBXGroup; children = ( @@ -1636,6 +1731,7 @@ DDC7E5CD2DC6637800EB1127 /* Storage */, DDEF503D2D32753A00999A5D /* Task */, FCC68871248A736700A0279D /* ViewControllers */, + D58FD15DB5B78FC38B3864F1 /* Onboarding */, ); path = LoopFollow; sourceTree = ""; @@ -1723,6 +1819,7 @@ DDDC01DC2E244B3100D9975C /* JWTManager.swift */, A1A1A10002000000A0CFEED2 /* LogRedactor.swift */, 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */, + 0A213D72341EF2C558030E88 /* NotificationAuthorization.swift */, ); path = Helpers; sourceTree = ""; @@ -1760,8 +1857,6 @@ 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, ); name = LoopFollowLAExtensionExtension; - packageProductDependencies = ( - ); productName = LoopFollowLAExtensionExtension; productReference = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -1783,8 +1878,6 @@ DDCC3AD72DDE1790006F1C10 /* Tests */, ); name = Tests; - packageProductDependencies = ( - ); productName = Tests; productReference = DDCC3AD62DDE1790006F1C10 /* Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -1812,8 +1905,6 @@ 65AC26702ED245DF00421360 /* Treatments */, ); name = LoopFollow; - packageProductDependencies = ( - ); productName = LoopFollow; productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; @@ -1849,8 +1940,6 @@ Base, ); mainGroup = FC97880B2485969B00A7906C; - packageReferences = ( - ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; projectRoot = ""; @@ -2411,6 +2500,19 @@ DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */, DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */, DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */, + CCE18AC5C70DD24C4F07EEEF /* OnboardingStep.swift in Sources */, + 6D32AE9BDBC241941EAD3D53 /* OnboardingViewModel.swift in Sources */, + 63AE109876D073B929203D51 /* OnboardingContainerView.swift in Sources */, + F9E4698DE8AE0D5FEB518AF1 /* OnboardingStepHeader.swift in Sources */, + 0E7F523C7C777DFDDFFCC2A8 /* WelcomeStepView.swift in Sources */, + 510B3641E5D3A7FBBAA713DD /* DataSourceChoiceStepView.swift in Sources */, + 2EADEE2EE5B46EF64ADF7348 /* NightscoutConnectStepView.swift in Sources */, + F373C36F29C2B30181012301 /* DexcomConnectStepView.swift in Sources */, + 6953970EE39241506F90FF5B /* UnitsStepView.swift in Sources */, + 4B286B98268852C8ACF98E8E /* AlarmsStepView.swift in Sources */, + 8B5DC3C7CB15EFE705F89952 /* CompletionStepView.swift in Sources */, + F957B82F8D80D5D762CDE068 /* LoopFollowLogo.swift in Sources */, + F38017DF9689DF1F7639653A /* NotificationAuthorization.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LoopFollow/Alarm/AlarmListView.swift b/LoopFollow/Alarm/AlarmListView.swift index 3240208db..9aad1b95d 100644 --- a/LoopFollow/Alarm/AlarmListView.swift +++ b/LoopFollow/Alarm/AlarmListView.swift @@ -154,6 +154,9 @@ struct AlarmListView: View { AddAlarmSheet { type in let new = Alarm(type: type) store.value.append(new) + // First alarm the user adds is the moment notifications become + // useful — request authorization here rather than at app launch. + NotificationAuthorization.requestIfNeeded() sheetInfo = .editor(id: new.id, isNew: true) } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index f3a643477..042aea445 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -2,7 +2,6 @@ // AppDelegate.swift import AVFoundation -import EventKit import UIKit import UserNotifications @@ -13,21 +12,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() - let options: UNAuthorizationOptions = [.alert, .sound, .badge] - notificationCenter.requestAuthorization(options: options) { - didAllow, _ in - if !didAllow { - LogManager.shared.log(category: .general, message: "User has declined notifications") - } - } - - let store = EKEventStore() - store.requestCalendarAccess { granted, error in - if !granted { - LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))") - return - } - } + // Notification and calendar permissions are no longer requested here. + // They're deferred to the moment the user opts into the feature that + // needs them (alarms request notifications via NotificationAuthorization; + // the Calendar settings screen requests calendar access), so a fresh + // install isn't fronted with permission prompts before onboarding. let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) @@ -35,7 +24,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self - _ = BLEManager.shared + // Only spin up Bluetooth if the user has chosen a BLE-based background + // refresh. Initializing BLEManager creates a CBCentralManager, which + // triggers the Bluetooth permission prompt — deferring it keeps that + // prompt off fresh installs until the feature is actually enabled. + if Storage.shared.backgroundRefreshType.value.isBluetooth { + _ = BLEManager.shared + } // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index 140204358..15fb71c6d 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -15,6 +15,7 @@ struct MainTabView: View { @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition @State private var showTelemetryConsent = false + @State private var showOnboarding = false private var orderedItems: [TabItem] { Storage.shared.orderedTabBarItems() @@ -47,14 +48,21 @@ struct MainTabView: View { // onAppear (not app launch) keeps it off the BG-only refresh path. MainViewController.bootstrap() - // One-time consent prompt. Previously presented by SceneDelegate, - // which was removed in the storyboard→SwiftUI migration; without - // this, fresh installs stay permanently undecided and telemetry - // never sends. The storage flag keeps it to a single appearance. - if !Storage.shared.telemetryConsentDecisionMade.value { - showTelemetryConsent = true + // Show the first-run onboarding once for everyone. Returning users + // get a prominent Skip on the welcome screen. The telemetry consent + // prompt is deferred until onboarding is dismissed so the two never + // appear on top of one another. + if !Storage.shared.hasCompletedOnboarding.value { + showOnboarding = true + } else { + presentTelemetryConsentIfNeeded() } } + .fullScreenCover(isPresented: $showOnboarding, onDismiss: { + presentTelemetryConsentIfNeeded() + }) { + OnboardingContainerView(onClose: { showOnboarding = false }) + } .sheet(isPresented: $showTelemetryConsent) { // User must explicitly choose — no swipe-to-dismiss. TelemetryConsentView() @@ -62,6 +70,16 @@ struct MainTabView: View { } } + // One-time telemetry consent prompt. Previously presented by SceneDelegate, + // which was removed in the storyboard→SwiftUI migration; without this, fresh + // installs stay permanently undecided and telemetry never sends. The storage + // flag keeps it to a single appearance. + private func presentTelemetryConsentIfNeeded() { + if !Storage.shared.telemetryConsentDecisionMade.value { + showTelemetryConsent = true + } + } + @ViewBuilder private func tabContent(for item: TabItem) -> some View { switch item { diff --git a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift index c4a39874c..31184db0e 100644 --- a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift +++ b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift @@ -8,6 +8,12 @@ import Foundation class BLEManager: NSObject, ObservableObject { static let shared = BLEManager() + /// Whether the shared instance has been created (and therefore a + /// CBCentralManager exists / the Bluetooth prompt has been triggered). + /// Reading this does not instantiate `shared`, so callers can avoid forcing + /// Bluetooth initialization — and its permission prompt — when not needed. + private(set) static var isInitialized = false + @Published private(set) var devices: [BLEDevice] = [] private var centralManager: CBCentralManager! @@ -15,6 +21,7 @@ class BLEManager: NSObject, ObservableObject { override private init() { super.init() + BLEManager.isInitialized = true centralManager = CBCentralManager( delegate: self, diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift index 21cbdf43f..1bf23fe0c 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift @@ -33,6 +33,12 @@ class BackgroundRefreshSettingsViewModel: ObservableObject { private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) { LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)") - BLEManager.shared.disconnect() + // Touch BLEManager only when switching to a Bluetooth mode (the user is + // opting in, so the permission prompt belongs here) or when it's already + // running and needs to be torn down. Switching between non-BLE modes must + // not initialize Bluetooth — that would prompt without cause. + if newValue.isBluetooth || BLEManager.isInitialized { + BLEManager.shared.disconnect() + } } } diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index 0ba3664b1..8b844c983 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -76,7 +76,10 @@ class BackgroundAlertManager { removeDeliveredNotifications() let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth - let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval() + // Only query BLEManager for a Bluetooth mode — touching it otherwise would + // initialize CoreBluetooth and trigger the permission prompt for users + // (e.g. Silent Tune) who never opted into Bluetooth. + let expectedHeartbeat = isBluetoothActive ? BLEManager.shared.expectedHeartbeatInterval() : nil // Define alerts let alerts: [BackgroundAlert] = [ diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 04c5ff14b..cf698fbbe 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -1,6 +1,7 @@ // LoopFollow // NightscoutUtils.swift +import CryptoKit import Foundation class NightscoutUtils { @@ -385,6 +386,125 @@ class NightscoutUtils { return responseString } + // MARK: - Token Provisioning + + /// Name of the Nightscout authorization subject LoopFollow creates when a + /// user provisions a token from their API secret. + static let provisionedSubjectName = "LoopFollow" + + private struct AuthSubject: Decodable { + let id: String? + let name: String? + let accessToken: String? + let roles: [String]? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case name, accessToken, roles + } + } + + /// Creates (or reuses) a read-only Nightscout access token using the site's + /// API secret. The secret only authorizes these requests and is never + /// persisted. Returns the access token for a `readable` subject named + /// `provisionedSubjectName`. + /// + /// The full API secret authenticates as Nightscout's `admin` role (the `*` + /// permission), which includes `admin:api:subjects:create`. + /// + /// Nightscout serves the subjects list from an in-memory cache that doesn't + /// refresh promptly after a write, so a freshly-created subject (and its + /// token) can't be read back reliably right after creating it. Instead we + /// derive the token locally: it's a pure function of the subject's `_id` + /// (returned by the create call) and the API secret. See `accessToken(for:)`. + static func provisionReadOnlyToken(url: String, secret: String) async throws -> String { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { throw NightscoutError.emptyAddress } + guard let baseURL = URL(string: trimmedURL), + trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://") + else { throw NightscoutError.invalidURL } + + let secretHash = sha1Hex(secret) + + // Reuse an existing subject if one is already visible (idempotent re-runs + // once the site's cache has caught up). + if let existing = try await fetchProvisionedToken(baseURL: baseURL, secretHash: secretHash) { + return existing + } + + let id = try await createReadOnlySubject(baseURL: baseURL, secretHash: secretHash) + return accessToken(forName: provisionedSubjectName, id: id, secretHash: secretHash) + } + + private static func fetchProvisionedToken(baseURL: URL, secretHash: String) async throws -> String? { + let url = baseURL.appendingPathComponent("api/v2/authorization/subjects") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(secretHash, forHTTPHeaderField: "api-secret") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.cachePolicy = .reloadIgnoringLocalCacheData + + let (data, response) = try await URLSession.shared.data(for: request) + try validateProvisioningResponse(response) + + let subjects = try JSONDecoder().decode([AuthSubject].self, from: data) + return subjects.first(where: { $0.name == provisionedSubjectName })?.accessToken + } + + /// Creates the subject and returns its `_id`. + private static func createReadOnlySubject(baseURL: URL, secretHash: String) async throws -> String { + let url = baseURL.appendingPathComponent("api/v2/authorization/subjects") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(secretHash, forHTTPHeaderField: "api-secret") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "name": provisionedSubjectName, + "roles": ["readable"], + ]) + + let (data, response) = try await URLSession.shared.data(for: request) + try validateProvisioningResponse(response) + + let subject = try JSONDecoder().decode(AuthSubject.self, from: data) + guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown } + return id + } + + /// Reproduces Nightscout's subject-token derivation (`lib/authorization`): + /// abbrev = name lowercased, non-`\w` characters removed, first 10 chars + /// digest = sha1( sha1Hex(apiSecret) + subjectId ) + /// token = "\(abbrev)-\(digest[0..<16])" + private static func accessToken(forName name: String, id: String, secretHash: String) -> String { + let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_") + let abbrev = String(name.lowercased().filter { allowed.contains($0) }.prefix(10)) + let digest = sha1Hex(secretHash + id) + return abbrev + "-" + String(digest.prefix(16)) + } + + private static func validateProvisioningResponse(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw NightscoutError.networkError + } + switch http.statusCode { + case 200 ..< 300: + return + case 401, 403: + // The API secret was missing or wrong. + throw NightscoutError.invalidToken + case 404: + throw NightscoutError.siteNotFound + default: + throw NightscoutError.unknown + } + } + + private static func sha1Hex(_ string: String) -> String { + Insecure.SHA1.hash(data: Data(string.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + static func extractErrorReason(from responseString: String) -> String { // 1) Try to parse the entire string as JSON and return the "message" if let data = responseString.data(using: .utf8) { diff --git a/LoopFollow/Helpers/NotificationAuthorization.swift b/LoopFollow/Helpers/NotificationAuthorization.swift new file mode 100644 index 000000000..f93d948c2 --- /dev/null +++ b/LoopFollow/Helpers/NotificationAuthorization.swift @@ -0,0 +1,23 @@ +// LoopFollow +// NotificationAuthorization.swift + +import UserNotifications + +/// Requests notification authorization lazily, the first time the user opts into +/// a feature that needs it (alarms). This keeps the system prompt off the very +/// first launch so it doesn't front the onboarding flow. +enum NotificationAuthorization { + /// Asks for authorization only when the user hasn't decided yet. Safe to call + /// repeatedly — it's a no-op once the status is determined. + static func requestIfNeeded() { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + guard settings.authorizationStatus == .notDetermined else { return } + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + if !granted { + LogManager.shared.log(category: .general, message: "User has declined notifications") + } + } + } + } +} diff --git a/LoopFollow/Onboarding/LoopFollowLogo.swift b/LoopFollow/Onboarding/LoopFollowLogo.swift new file mode 100644 index 000000000..f3f23e39b --- /dev/null +++ b/LoopFollow/Onboarding/LoopFollowLogo.swift @@ -0,0 +1,108 @@ +// LoopFollow +// LoopFollowLogo.swift + +import SwiftUI + +/// The LoopFollow mark, rebuilt in SwiftUI as the full app-icon face — a glassy +/// rounded square with the blue "loop" ring — so it has real visual mass when +/// tilted in 3D (a bare ring collapses to a line edge-on). +struct LoopFollowLogo: View { + var size: CGFloat = 120 + + // Colors sampled from the app icon (loopfollow-icon.svg). + private let lightBlue = Color(red: 0.357, green: 0.639, blue: 0.961) // #5BA3F5 + private let midBlue = Color(red: 0.290, green: 0.565, blue: 0.886) // #4A90E2 + private let darkBlue = Color(red: 0.227, green: 0.482, blue: 0.784) // #3A7BC8 + + var body: some View { + let corner = size * 0.225 + let ringInset = size * 0.20 + let ringWidth = size * 0.17 + + ZStack { + // Glassy white card (the icon face). + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [Color(white: 0.99), Color.white, Color(white: 0.93)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + // Soft sheen across the top half. + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.75), Color.clear], + startPoint: .top, + endPoint: .center + ) + ) + + // Blue glass ring. + Circle() + .stroke( + LinearGradient( + colors: [lightBlue, midBlue, darkBlue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: ringWidth + ) + .padding(ringInset) + + // Inner shadow on the ring for depth. + Circle() + .stroke(Color.black.opacity(0.16), lineWidth: ringWidth * 0.2) + .blur(radius: ringWidth * 0.12) + .padding(ringInset) + .mask(Circle().stroke(lineWidth: ringWidth).padding(ringInset)) + + // Specular highlight on the upper-left of the ring. + Circle() + .trim(from: 0.55, to: 0.80) + .stroke( + LinearGradient( + colors: [Color.white.opacity(0.9), Color.white.opacity(0.0)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + style: StrokeStyle(lineWidth: ringWidth * 0.42, lineCap: .round) + ) + .blur(radius: ringWidth * 0.08) + .padding(ringInset) + } + .frame(width: size, height: size) + } +} + +/// LoopFollow logo that lands like a coin: it starts edge-on (rotated 90° about +/// the vertical axis) and springs open to face the viewer, overshooting a little +/// past flat and rocking back to rest. Respects Reduce Motion by rendering flat. +struct AnimatedLoopFollowLogo: View { + var size: CGFloat = 140 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var angle: Double = 90 + + var body: some View { + LoopFollowLogo(size: size) + .rotation3DEffect( + .degrees(angle), + axis: (x: 0, y: 1, z: 0), + perspective: 0.7 + ) + // Grounding shadow so the landing reads as dimensional. + .shadow(color: .black.opacity(0.28), radius: size * 0.08, x: 0, y: size * 0.06) + .onAppear { + if reduceMotion { + angle = 0 + } else { + withAnimation(.spring(response: 0.85, dampingFraction: 0.5).delay(0.15)) { + angle = 0 + } + } + } + } +} diff --git a/LoopFollow/Onboarding/OnboardingContainerView.swift b/LoopFollow/Onboarding/OnboardingContainerView.swift new file mode 100644 index 000000000..a53168c58 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,136 @@ +// LoopFollow +// OnboardingContainerView.swift + +import SwiftUI + +/// Root of the first-run onboarding wizard. Owns the shared chrome — progress +/// bar and Back/Next footer — and swaps in the view for the current step. +struct OnboardingContainerView: View { + @StateObject private var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(onClose: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: OnboardingViewModel(onClose: onClose)) + } + + var body: some View { + VStack(spacing: 0) { + if viewModel.step.showsChrome { + header + } + + stepContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if viewModel.step.showsChrome { + footer + } + } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + // MARK: - Chrome + + private var header: some View { + HStack(spacing: 12) { + OnboardingProgressBar(progress: viewModel.progress) + Button("Skip") { viewModel.skip() } + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + } + + @ViewBuilder + private var stepContent: some View { + let content = Group { + switch viewModel.step { + case .welcome: + WelcomeStepView(viewModel: viewModel) + case .dataSource: + DataSourceChoiceStepView(viewModel: viewModel) + case .connect: + switch viewModel.dataSource { + case .dexcom: + DexcomConnectStepView(viewModel: viewModel.dexcomViewModel) + default: + NightscoutConnectStepView(viewModel: viewModel.nightscoutViewModel) + } + case .units: + UnitsStepView() + case .alarms: + AlarmsStepView(viewModel: viewModel) + case .completion: + CompletionStepView(viewModel: viewModel) + } + } + + if reduceMotion { + content.id(viewModel.step) + } else { + content + .id(viewModel.step) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + } + + private var footer: some View { + HStack { + if viewModel.step.previous != nil { + Button { + withStepAnimation { viewModel.goBack() } + } label: { + Label("Back", systemImage: "chevron.left") + .font(.body.weight(.medium)) + } + .buttonStyle(.bordered) + } + + Spacer() + + Button { + withStepAnimation { viewModel.advance() } + } label: { + Text("Continue") + .font(.body.weight(.semibold)) + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canProceed) + } + .padding() + .background(.bar) + } + + private func withStepAnimation(_ change: () -> Void) { + if reduceMotion { + change() + } else { + withAnimation(.easeInOut(duration: 0.3)) { change() } + } + } +} + +/// Thin segmented progress indicator shown at the top of each chrome'd step. +private struct OnboardingProgressBar: View { + let progress: Double + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color(.systemGray5)) + Capsule() + .fill(Color.accentColor) + .frame(width: max(0, min(1, progress)) * geo.size.width) + } + } + .frame(height: 6) + } +} diff --git a/LoopFollow/Onboarding/OnboardingStep.swift b/LoopFollow/Onboarding/OnboardingStep.swift new file mode 100644 index 000000000..90a20f6b9 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStep.swift @@ -0,0 +1,37 @@ +// LoopFollow +// OnboardingStep.swift + +import Foundation + +/// The ordered steps of the first-run onboarding wizard. +/// +/// `connect` renders either the Nightscout or Dexcom screen depending on the +/// data source the user picks in `dataSource`, so the ordering stays linear and +/// the progress indicator has a stable length. +enum OnboardingStep: Int, CaseIterable { + case welcome + case dataSource + case connect + case units + case alarms + case completion + + var next: OnboardingStep? { + OnboardingStep(rawValue: rawValue + 1) + } + + var previous: OnboardingStep? { + OnboardingStep(rawValue: rawValue - 1) + } + + /// Steps that show the progress bar and the Back/Next footer. The welcome and + /// completion screens are full-bleed and provide their own call-to-action. + var showsChrome: Bool { + switch self { + case .welcome, .completion: + return false + case .dataSource, .connect, .units, .alarms: + return true + } + } +} diff --git a/LoopFollow/Onboarding/OnboardingStepHeader.swift b/LoopFollow/Onboarding/OnboardingStepHeader.swift new file mode 100644 index 000000000..cabc416a5 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStepHeader.swift @@ -0,0 +1,32 @@ +// LoopFollow +// OnboardingStepHeader.swift + +import SwiftUI + +/// Consistent icon + title + subtitle header used at the top of each step body. +struct OnboardingStepHeader: View { + let systemImage: String + let title: String + let subtitle: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .padding(.bottom, 2) + + Text(title) + .font(.title2.weight(.bold)) + .multilineTextAlignment(.center) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.top, 8) + } +} diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift new file mode 100644 index 000000000..051fd8105 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -0,0 +1,133 @@ +// LoopFollow +// OnboardingViewModel.swift + +import Combine +import SwiftUI + +/// Drives the onboarding wizard: tracks the current step, the chosen data +/// source, the seeded alarms, and persists everything when the user finishes. +/// +/// The child connection view models are the same ones used by the regular +/// settings screens, so URL/token/credential entry and validation behave +/// identically here and there. +@MainActor +final class OnboardingViewModel: ObservableObject { + enum DataSource: Hashable { + case nightscout + case dexcom + } + + /// A single default alarm offered on the alarms step. + struct SeedAlarm: Identifiable { + let id = UUID() + var alarm: Alarm + var isEnabled: Bool = true + + var type: AlarmType { alarm.type } + } + + @Published var step: OnboardingStep = .welcome + @Published var dataSource: DataSource? + @Published var seedAlarms: [SeedAlarm] + + let nightscoutViewModel = NightscoutSettingsViewModel() + let dexcomViewModel = DexcomSettingsViewModel() + + /// Called to dismiss the onboarding cover. + private let onClose: () -> Void + private var cancellables = Set() + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + seedAlarms = [.low, .high, .missedReading, .notLooping, .battery] + .map { SeedAlarm(alarm: Alarm(type: $0)) } + + // Re-publish child changes so the footer's `canProceed` stays in sync + // with live connection validation. + nightscoutViewModel.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + dexcomViewModel.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + } + + // MARK: - Derived state + + /// True when the user already has a working data source — used to make + /// skipping prominent for returning users. + var isAlreadyConfigured: Bool { + let nightscout = !Storage.shared.url.value.isEmpty + let dexcom = !Storage.shared.shareUserName.value.isEmpty + && !Storage.shared.sharePassword.value.isEmpty + return nightscout || dexcom + } + + var canProceed: Bool { + switch step { + case .welcome, .units, .alarms, .completion: + return true + case .dataSource: + return dataSource != nil + case .connect: + switch dataSource { + case .nightscout: return nightscoutViewModel.isConnected + case .dexcom: return dexcomViewModel.hasCredentials + case .none: return false + } + } + } + + /// Progress fraction (0...1) across the chrome'd steps, for the progress bar. + var progress: Double { + let total = Double(OnboardingStep.allCases.count - 1) + guard total > 0 else { return 0 } + return Double(step.rawValue) / total + } + + // MARK: - Navigation + + func advance() { + guard let next = step.next else { + finish() + return + } + step = next + } + + func goBack() { + guard let previous = step.previous else { return } + step = previous + } + + /// Skip the rest of setup. Marks onboarding complete without seeding alarms + /// or touching units, leaving any existing configuration untouched. + func skip() { + Storage.shared.hasCompletedOnboarding.value = true + onClose() + } + + /// Finish setup: seed selected alarms, mark units/onboarding complete, and + /// kick a refresh so the home screen loads data immediately. + func finish() { + persistSeededAlarms() + Storage.shared.hasConfiguredUnits.value = true + Storage.shared.hasCompletedOnboarding.value = true + onClose() + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + // MARK: - Alarm seeding + + private func persistSeededAlarms() { + var alarms = Storage.shared.alarms.value + let existingTypes = Set(alarms.map(\.type)) + + for seed in seedAlarms where seed.isEnabled { + guard !existingTypes.contains(seed.type) else { continue } + alarms.append(seed.alarm) + } + + Storage.shared.alarms.value = alarms + } +} diff --git a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift new file mode 100644 index 000000000..aedfd5ed3 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift @@ -0,0 +1,132 @@ +// LoopFollow +// AlarmsStepView.swift + +import SwiftUI + +struct AlarmsStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "bell.badge.fill", + title: "Useful alarms", + subtitle: "We'll set up a few safety alarms with sensible defaults. Turn off any you don't want and adjust the rest." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + ForEach($viewModel.seedAlarms) { $seed in + Toggle(isOn: $seed.isEnabled) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(meta(for: seed.type).title) + Text(meta(for: seed.type).detail) + .font(.caption) + .foregroundColor(.secondary) + } + } icon: { + Image(systemName: meta(for: seed.type).icon) + } + } + + if seed.isEnabled { + control(for: $seed) + } + } + } footer: { + Text("These come with sensible defaults — fine-tune them any time in the Alarms tab.") + } + } + } + + // MARK: - Per-alarm control + + @ViewBuilder + private func control(for seed: Binding) -> some View { + switch seed.wrappedValue.type { + case .low: + BGPicker( + title: "Alert below", + range: 40 ... 150, + value: doubleBinding(seed, keyPath: \.belowBG, default: 80) + ) + case .high: + BGPicker( + title: "Alert above", + range: 120 ... 350, + value: doubleBinding(seed, keyPath: \.aboveBG, default: 180) + ) + case .missedReading: + stepperRow(seed, label: "No reading for", range: 11 ... 121, step: 5, unit: "min", default: 16) + case .notLooping: + stepperRow(seed, label: "No loop for", range: 16 ... 61, step: 5, unit: "min", default: 31) + case .battery: + stepperRow(seed, label: "At or below", range: 0 ... 100, step: 5, unit: "%", default: 20) + default: + EmptyView() + } + } + + private func stepperRow( + _ seed: Binding, + label: String, + range: ClosedRange, + step: Double, + unit: String, + default def: Double + ) -> some View { + let value = doubleBinding(seed, keyPath: \.threshold, default: def) + return Stepper(value: value, in: range, step: step) { + HStack { + Text(label) + Spacer() + Text("\(Int(value.wrappedValue)) \(unit)") + .foregroundColor(.secondary) + } + } + } + + private func doubleBinding( + _ seed: Binding, + keyPath: WritableKeyPath, + default def: Double + ) -> Binding { + Binding( + get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? def }, + set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 } + ) + } + + // MARK: - Copy + + private struct AlarmMeta { + let title: String + let detail: String + let icon: String + } + + private func meta(for type: AlarmType) -> AlarmMeta { + switch type { + case .low: + return AlarmMeta(title: "Low glucose", detail: "Warns when glucose is low, now or soon.", icon: "arrow.down.circle.fill") + case .high: + return AlarmMeta(title: "High glucose", detail: "Warns when glucose stays high.", icon: "arrow.up.circle.fill") + case .missedReading: + return AlarmMeta(title: "Missed readings", detail: "Warns when glucose stops updating.", icon: "wifi.slash") + case .notLooping: + return AlarmMeta(title: "Not looping", detail: "Warns when the loop stops running.", icon: "arrow.triangle.2.circlepath") + case .battery: + return AlarmMeta(title: "Phone battery", detail: "Warns when your phone battery is low.", icon: "battery.25") + default: + return AlarmMeta(title: type.rawValue, detail: "", icon: "bell.fill") + } + } +} diff --git a/LoopFollow/Onboarding/Steps/CompletionStepView.swift b/LoopFollow/Onboarding/Steps/CompletionStepView.swift new file mode 100644 index 000000000..ad07f285c --- /dev/null +++ b/LoopFollow/Onboarding/Steps/CompletionStepView.swift @@ -0,0 +1,60 @@ +// LoopFollow +// CompletionStepView.swift + +import SwiftUI + +struct CompletionStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animate = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 20) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 96, weight: .semibold)) + .foregroundStyle(.green.gradient) + .scaleEffect(animate || reduceMotion ? 1 : 0.6) + .opacity(animate || reduceMotion ? 1 : 0) + + Text("You're all set") + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + + Text("LoopFollow is ready to go. You can adjust everything later from the Menu and Alarms tabs.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + Button { viewModel.finish() } label: { + Text("Done") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + // The user just set up alarms, so this is the natural moment to ask + // for notification permission — on context, and after onboarding + // rather than fronting it on first launch. + if viewModel.seedAlarms.contains(where: { $0.isEnabled }) { + NotificationAuthorization.requestIfNeeded() + } + + guard !reduceMotion else { return } + withAnimation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.1)) { + animate = true + } + } + } +} diff --git a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift new file mode 100644 index 000000000..5247feacd --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift @@ -0,0 +1,77 @@ +// LoopFollow +// DataSourceChoiceStepView.swift + +import SwiftUI + +struct DataSourceChoiceStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + + var body: some View { + ScrollView { + VStack(spacing: 24) { + OnboardingStepHeader( + systemImage: "antenna.radiowaves.left.and.right", + title: "Choose a data source", + subtitle: "LoopFollow needs somewhere to read glucose from. Pick one now — you can add the other later in Settings." + ) + + VStack(spacing: 14) { + choiceCard( + source: .nightscout, + icon: "globe", + title: "Nightscout", + detail: "Follow a Nightscout site. Works with Loop, Trio, and most uploaders. Enables the full set of LoopFollow features." + ) + choiceCard( + source: .dexcom, + icon: "drop.fill", + title: "Dexcom Share", + detail: "Follow glucose directly from a Dexcom Share account. Simplest option when there's no Nightscout site." + ) + } + .padding(.horizontal) + } + .padding(.bottom, 24) + } + } + + private func choiceCard(source: OnboardingViewModel.DataSource, icon: String, title: String, detail: String) -> some View { + let isSelected = viewModel.dataSource == source + return Button { + viewModel.dataSource = source + } label: { + HStack(alignment: .top, spacing: 14) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundColor(.primary) + Text(detail) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isSelected ? Color.accentColor : Color(.systemGray3)) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} diff --git a/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift new file mode 100644 index 000000000..9b4a4c179 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift @@ -0,0 +1,60 @@ +// LoopFollow +// DexcomConnectStepView.swift + +import SwiftUI + +struct DexcomConnectStepView: View { + @ObservedObject var viewModel: DexcomSettingsViewModel + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "drop.fill", + title: "Connect Dexcom Share", + subtitle: "Sign in with the Dexcom Share account you use to follow glucose." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section(header: Text("Dexcom Share")) { + HStack { + Text("User Name") + TextField("Enter User Name", text: $viewModel.userName) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } + + HStack { + Text("Password") + TogglableSecureInput( + placeholder: "Enter Password", + text: $viewModel.password, + style: .singleLine + ) + } + + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("NON-US").tag("NON-US") + } + .pickerStyle(.segmented) + } + + Section { + HStack { + Image(systemName: viewModel.hasCredentials ? "checkmark.circle.fill" : "circle") + .foregroundColor(viewModel.hasCredentials ? .green : .secondary) + Text(viewModel.hasCredentials ? "Credentials entered" : "Enter your username and password") + .foregroundColor(.secondary) + } + } + } + } +} diff --git a/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift new file mode 100644 index 000000000..9276c744f --- /dev/null +++ b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift @@ -0,0 +1,185 @@ +// LoopFollow +// NightscoutConnectStepView.swift + +import SwiftUI + +struct NightscoutConnectStepView: View { + @ObservedObject var viewModel: NightscoutSettingsViewModel + + private enum TokenMode: Hashable { + case haveToken + case createFromSecret + } + + @State private var mode: TokenMode = .haveToken + @State private var apiSecret: String = "" + @State private var isProvisioning = false + @State private var provisioningError: String? + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "globe", + title: "Connect to Nightscout", + subtitle: "Enter your site address. If your site needs a token, LoopFollow can create a read-only one for you." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + urlSection + tokenModeSection + + switch mode { + case .haveToken: + tokenSection + case .createFromSecret: + secretSection + } + + statusSection + } + } + + // MARK: - Sections + + private var urlSection: some View { + Section(header: Text("URL")) { + TextField("https://your-site.example.com", text: $viewModel.nightscoutURL) + .textContentType(.URL) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: viewModel.nightscoutURL) { newValue in + viewModel.processURL(newValue) + } + } + } + + private var tokenModeSection: some View { + Section { + Picker("Token", selection: $mode) { + Text("I have a token").tag(TokenMode.haveToken) + Text("Create one for me").tag(TokenMode.createFromSecret) + } + .pickerStyle(.segmented) + } footer: { + if mode == .createFromSecret { + Text("Your API secret is used once to create a read-only access token and is never stored.") + } else { + Text("Paste a token, or a full Nightscout URL that includes a token. Leave empty if your site is public.") + } + } + } + + private var tokenSection: some View { + Section(header: Text("Access Token")) { + HStack { + Text("Token") + TogglableSecureInput( + placeholder: "Enter Token", + text: $viewModel.nightscoutToken, + style: .singleLine, + textContentType: .password + ) + } + } + } + + private var secretSection: some View { + Section(header: Text("API Secret")) { + HStack { + Text("Secret") + TogglableSecureInput( + placeholder: "Enter API Secret", + text: $apiSecret, + style: .singleLine, + textContentType: .password + ) + } + + Button(action: createToken) { + HStack { + Spacer() + if isProvisioning { + ProgressView() + } else { + Text("Create Read-Only Token") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(isProvisioning + || apiSecret.isEmpty + || viewModel.nightscoutURL.isEmpty) + + if let provisioningError { + Text(provisioningError) + .font(.footnote) + .foregroundColor(.red) + } + } + } + + private var statusSection: some View { + Section(header: Text("Status")) { + HStack { + Text(viewModel.nightscoutStatus) + if viewModel.isConnected { + Spacer() + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } + } + } + } + + // MARK: - Token provisioning + + private func createToken() { + provisioningError = nil + isProvisioning = true + let url = viewModel.nightscoutURL + let secret = apiSecret + + Task { + do { + let token = try await NightscoutUtils.provisionReadOnlyToken(url: url, secret: secret) + await MainActor.run { + apiSecret = "" + viewModel.nightscoutToken = token + isProvisioning = false + } + } catch { + await MainActor.run { + isProvisioning = false + provisioningError = message(for: error) + } + } + } + } + + private func message(for error: Error) -> String { + guard let nsError = error as? NightscoutUtils.NightscoutError else { + return "Could not create a token. Please try again." + } + switch nsError { + case .invalidToken: + return "That API secret was rejected. Check it and try again." + case .invalidURL, .emptyAddress: + return "Please enter a valid site URL first." + case .siteNotFound: + return "Couldn't reach that site. Check the URL." + case .networkError: + return "Network error. Check your connection and try again." + case .tokenRequired, .unknown: + return "Could not create a token. Please try again." + } + } +} diff --git a/LoopFollow/Onboarding/Steps/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift new file mode 100644 index 000000000..e510cc6bd --- /dev/null +++ b/LoopFollow/Onboarding/Steps/UnitsStepView.swift @@ -0,0 +1,26 @@ +// LoopFollow +// UnitsStepView.swift + +import SwiftUI + +struct UnitsStepView: View { + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "ruler", + title: "Units & metrics", + subtitle: "Choose how glucose and your statistics are displayed. You can change any of this later in Settings." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + UnitsConfigurationView() + } + } +} diff --git a/LoopFollow/Onboarding/Steps/WelcomeStepView.swift b/LoopFollow/Onboarding/Steps/WelcomeStepView.swift new file mode 100644 index 000000000..da9cfad51 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/WelcomeStepView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// WelcomeStepView.swift + +import SwiftUI + +struct WelcomeStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animate = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 20) { + AnimatedLoopFollowLogo(size: 140) + .frame(height: 160) + .opacity(animate || reduceMotion ? 1 : 0) + + Text("Welcome to LoopFollow") + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + + Text(viewModel.isAlreadyConfigured + ? "You're already set up. You can skip this guide, or walk through it to review your settings." + : "Let's get you connected to your data and set up a few useful alarms. It only takes a minute.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + VStack(spacing: 12) { + if viewModel.isAlreadyConfigured { + Button { viewModel.skip() } label: { + primaryLabel("Skip") + } + .buttonStyle(.borderedProminent) + + Button { advance() } label: { + Text("Review setup anyway") + .font(.body.weight(.medium)) + } + } else { + Button { advance() } label: { + primaryLabel("Get Started") + } + .buttonStyle(.borderedProminent) + + Button { viewModel.skip() } label: { + Text("Skip for now") + .font(.body.weight(.medium)) + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.1)) { + animate = true + } + } + } + + private func primaryLabel(_ text: String) -> some View { + Text(text) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + + private func advance() { + if reduceMotion { + viewModel.advance() + } else { + withAnimation(.easeInOut(duration: 0.3)) { viewModel.advance() } + } + } +} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 6ee01522b..e6625e503 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -201,6 +201,7 @@ class Storage { var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") var hasConfiguredUnits = StorageValue(key: "hasConfiguredUnits", defaultValue: false) + var hasCompletedOnboarding = StorageValue(key: "hasCompletedOnboarding", defaultValue: false) var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder)) var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible)) From bf7b5a5b89fafd7028cddf6e90bc3be0d4976ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 20:33:04 +0200 Subject: [PATCH 2/3] Gate device/system onboarding alarms to Nightscout Not Looping and Low Battery rely on loop and uploader data that only a Nightscout site provides, so they are meaningless for a Dexcom-only follower. Offer the device/system alarm group during onboarding only when following Nightscout (or a Nightscout URL is already configured), and don't seed them otherwise. --- .../Onboarding/OnboardingViewModel.swift | 11 +++++++- .../Onboarding/Steps/AlarmsStepView.swift | 26 ++++++++++--------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift index 051fd8105..4ac25c5ed 100644 --- a/LoopFollow/Onboarding/OnboardingViewModel.swift +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -78,6 +78,15 @@ final class OnboardingViewModel: ObservableObject { } } + /// Whether a seeded alarm should be offered. Device/system alarms (Not + /// Looping, Low Battery, …) rely on loop and uploader data that only a + /// Nightscout site provides, so they're hidden for a Dexcom-only follower who + /// has no such data. Other groups are always offered. + func isSeedAlarmOffered(_ type: AlarmType) -> Bool { + guard type.group == .device else { return true } + return dataSource == .nightscout || !Storage.shared.url.value.isEmpty + } + /// Progress fraction (0...1) across the chrome'd steps, for the progress bar. var progress: Double { let total = Double(OnboardingStep.allCases.count - 1) @@ -123,7 +132,7 @@ final class OnboardingViewModel: ObservableObject { var alarms = Storage.shared.alarms.value let existingTypes = Set(alarms.map(\.type)) - for seed in seedAlarms where seed.isEnabled { + for seed in seedAlarms where seed.isEnabled && isSeedAlarmOffered(seed.type) { guard !existingTypes.contains(seed.type) else { continue } alarms.append(seed.alarm) } diff --git a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift index aedfd5ed3..6bc115399 100644 --- a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift +++ b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift @@ -24,21 +24,23 @@ struct AlarmsStepView: View { Section { ForEach($viewModel.seedAlarms) { $seed in - Toggle(isOn: $seed.isEnabled) { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(meta(for: seed.type).title) - Text(meta(for: seed.type).detail) - .font(.caption) - .foregroundColor(.secondary) + if viewModel.isSeedAlarmOffered(seed.type) { + Toggle(isOn: $seed.isEnabled) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(meta(for: seed.type).title) + Text(meta(for: seed.type).detail) + .font(.caption) + .foregroundColor(.secondary) + } + } icon: { + Image(systemName: meta(for: seed.type).icon) } - } icon: { - Image(systemName: meta(for: seed.type).icon) } - } - if seed.isEnabled { - control(for: $seed) + if seed.isEnabled { + control(for: $seed) + } } } } footer: { From 4961661c48e6288135f0f7de57caf0cb5f8489aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 14 Jun 2026 21:08:16 +0200 Subject: [PATCH 3/3] Refine onboarding copy and align Dexcom labels Polish the onboarding wording (data source, Nightscout token, Dexcom sign-in, units, alarms, and completion screens) and modernise the Dexcom field labels: "Username" instead of "User Name" and "Outside US" instead of "NON-US" (display only; stored server value is unchanged). Apply the same Dexcom label changes to the standalone Dexcom settings screen so the two stay consistent. --- LoopFollow/Onboarding/Steps/AlarmsStepView.swift | 2 +- LoopFollow/Onboarding/Steps/CompletionStepView.swift | 2 +- .../Onboarding/Steps/DataSourceChoiceStepView.swift | 2 +- LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift | 8 ++++---- LoopFollow/Onboarding/Steps/UnitsStepView.swift | 2 +- LoopFollow/Settings/DexcomSettingsView.swift | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift index 6bc115399..b987dc547 100644 --- a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift +++ b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift @@ -14,7 +14,7 @@ struct AlarmsStepView: View { OnboardingStepHeader( systemImage: "bell.badge.fill", title: "Useful alarms", - subtitle: "We'll set up a few safety alarms with sensible defaults. Turn off any you don't want and adjust the rest." + subtitle: "We'll set up a few commonly used alarms with sensible defaults. Turn off any you don't want and adjust the rest." ) .textCase(nil) .padding(.bottom, 8) diff --git a/LoopFollow/Onboarding/Steps/CompletionStepView.swift b/LoopFollow/Onboarding/Steps/CompletionStepView.swift index ad07f285c..4c6a09ce5 100644 --- a/LoopFollow/Onboarding/Steps/CompletionStepView.swift +++ b/LoopFollow/Onboarding/Steps/CompletionStepView.swift @@ -23,7 +23,7 @@ struct CompletionStepView: View { .font(.largeTitle.weight(.bold)) .multilineTextAlignment(.center) - Text("LoopFollow is ready to go. You can adjust everything later from the Menu and Alarms tabs.") + Text("You're ready to go. You can adjust everything later from the Menu and Alarms tabs.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) diff --git a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift index 5247feacd..6192b742f 100644 --- a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift +++ b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift @@ -12,7 +12,7 @@ struct DataSourceChoiceStepView: View { OnboardingStepHeader( systemImage: "antenna.radiowaves.left.and.right", title: "Choose a data source", - subtitle: "LoopFollow needs somewhere to read glucose from. Pick one now — you can add the other later in Settings." + subtitle: "LoopFollow needs a glucose data source. Pick one now — you can change or add more later in Settings." ) VStack(spacing: 14) { diff --git a/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift index 9b4a4c179..14680c01b 100644 --- a/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift +++ b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift @@ -14,7 +14,7 @@ struct DexcomConnectStepView: View { OnboardingStepHeader( systemImage: "drop.fill", title: "Connect Dexcom Share", - subtitle: "Sign in with the Dexcom Share account you use to follow glucose." + subtitle: "Sign in with the Dexcom Share account that shares glucose data." ) .textCase(nil) .padding(.bottom, 8) @@ -24,8 +24,8 @@ struct DexcomConnectStepView: View { Section(header: Text("Dexcom Share")) { HStack { - Text("User Name") - TextField("Enter User Name", text: $viewModel.userName) + Text("Username") + TextField("Enter Username", text: $viewModel.userName) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) @@ -42,7 +42,7 @@ struct DexcomConnectStepView: View { Picker("Server", selection: $viewModel.server) { Text("US").tag("US") - Text("NON-US").tag("NON-US") + Text("Outside US").tag("NON-US") } .pickerStyle(.segmented) } diff --git a/LoopFollow/Onboarding/Steps/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift index e510cc6bd..06e7ab52d 100644 --- a/LoopFollow/Onboarding/Steps/UnitsStepView.swift +++ b/LoopFollow/Onboarding/Steps/UnitsStepView.swift @@ -12,7 +12,7 @@ struct UnitsStepView: View { OnboardingStepHeader( systemImage: "ruler", title: "Units & metrics", - subtitle: "Choose how glucose and your statistics are displayed. You can change any of this later in Settings." + subtitle: "Choose how glucose values and statistics are displayed. You can change any of this later in Settings." ) .textCase(nil) .padding(.bottom, 8) diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 99a4dc32b..7a6794b42 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -14,8 +14,8 @@ struct DexcomSettingsView: View { Form { Section(header: Text("Dexcom Settings")) { HStack { - Text("User Name") - TextField("Enter User Name", text: $viewModel.userName) + Text("Username") + TextField("Enter Username", text: $viewModel.userName) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) @@ -32,7 +32,7 @@ struct DexcomSettingsView: View { Picker("Server", selection: $viewModel.server) { Text("US").tag("US") - Text("NON-US").tag("NON-US") + Text("Outside US").tag("NON-US") } .pickerStyle(SegmentedPickerStyle()) }