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/4] 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/4] 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/4] 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()) } From 7060920b461703ecb2fa53ce5e6bb9727292564e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 19 Jun 2026 00:45:15 +0200 Subject: [PATCH 4/4] Expand onboarding flow and harden Nightscout token setup Onboarding - Track progress by phase with a smooth bar and a per-phase counter - Add overview, general alarm settings, tab order, notification and telemetry steps, plus a "Copy from another phone" QR data source - Left-align text and tidy copy throughout Nightscout - Split connect into an address page and a token page shown only when needed - Add a live status pill; show "needs a token" as a positive state - Accept both array and object shapes from the subject-create response, fixing the create-token-fails-first-time case - Treat a freshly created but not-yet-accepted token as pending instead of an error so the user can continue Dexcom - Enable keychain autofill, verify credentials with a real login, and use a CGM-style icon Alarms - Offer more alarms one per page, on by default, with a few settings each - Skip alarm types the user already has and use each type's own icon Other - Defer notification, calendar and Bluetooth prompts away from launch - Add units explainer footers with correct values that follow the unit --- LoopFollow.xcodeproj/project.pbxproj | 122 +++++---- LoopFollow/Application/MainTabView.swift | 38 ++- LoopFollow/Helpers/NightscoutUtils.swift | 14 +- .../Helpers/NotificationAuthorization.swift | 13 +- LoopFollow/Helpers/Telemetry.swift | 2 +- .../NightscoutSettingsViewModel.swift | 132 +++++++++ .../Onboarding/OnboardingContainerView.swift | 70 ++--- .../Onboarding/OnboardingNavFooter.swift | 39 +++ LoopFollow/Onboarding/OnboardingStep.swift | 64 +++-- .../Onboarding/OnboardingStepHeader.swift | 10 +- .../Onboarding/OnboardingViewModel.swift | 249 +++++++++++++++-- .../Onboarding/Steps/AlarmsStepView.swift | 216 ++++++++++----- .../Onboarding/Steps/CompletionStepView.swift | 11 +- .../Steps/ConnectImportStepView.swift | 84 ++++++ .../Steps/DataSourceChoiceStepView.swift | 10 +- .../Steps/DexcomConnectStepView.swift | 41 ++- .../Steps/GeneralAlarmsStepView.swift | 83 ++++++ .../Steps/NightscoutConnectStepView.swift | 252 ++++++++++++++---- .../Steps/NotificationsStepView.swift | 67 +++++ .../Onboarding/Steps/OverviewStepView.swift | 72 +++++ .../Onboarding/Steps/TabOrderStepView.swift | 22 ++ .../Onboarding/Steps/TelemetryStepView.swift | 80 ++++++ .../Onboarding/Steps/WelcomeStepView.swift | 2 +- LoopFollow/Settings/DexcomSettingsView.swift | 4 +- .../Settings/DexcomSettingsViewModel.swift | 98 +++++++ .../Settings/UnitsConfigurationView.swift | 28 +- 26 files changed, 1544 insertions(+), 279 deletions(-) create mode 100644 LoopFollow/Onboarding/OnboardingNavFooter.swift create mode 100644 LoopFollow/Onboarding/Steps/ConnectImportStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/NotificationsStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/OverviewStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/TabOrderStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/TelemetryStepView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 116876e6d..6837ca211 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,13 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 048429FF512415FC9045CE14 /* OnboardingNavFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1982C63D6BFEC44041F36C /* OnboardingNavFooter.swift */; }; 0E7F523C7C777DFDDFFCC2A8 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */; }; - B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; - B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; - B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; - B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; + 15D060499FDE9355E7845378 /* TabOrderStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4FF72ABE12982B49BA9FC1 /* TabOrderStepView.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 2EADEE2EE5B46EF64ADF7348 /* NightscoutConnectStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */; }; + 3290221F2E1B7C6A6BB9FF61 /* OverviewStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201E9EC6620F89C9BC888A88 /* OverviewStepView.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 */; }; @@ -39,6 +38,7 @@ 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 */; }; + 549729AB8E048848A064676D /* GeneralAlarmsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCEFB52C371A466BA960F91F /* GeneralAlarmsStepView.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 */; }; @@ -76,6 +76,8 @@ 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 */; }; + 7FEB5B1C5045A3DE20432E25 /* TelemetryStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E83384DB3CE9667D1E5EB05C /* TelemetryStepView.swift */; }; + 8A8F1C9AB629385666F7BA4C /* NotificationsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B16E0A32D68AF70FC8C554 /* NotificationsStepView.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 */; }; @@ -85,6 +87,11 @@ 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 */; }; + B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; + B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; + B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; + B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; + B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000C1 /* QuickPickSectionHeader.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 */; }; @@ -120,7 +127,6 @@ DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; - B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000C1 /* QuickPickSectionHeader.swift */; }; DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; }; DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; }; DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */; }; @@ -158,6 +164,9 @@ 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 */; }; + DD50C10A2F60A00000000001 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = DD50C10A2F60A00000000003 /* SocketIO */; }; + DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */; }; + DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */; }; 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 */; }; @@ -303,6 +312,7 @@ 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 */; }; + EAA0499272BE286A7E42BCAE /* ConnectImportStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA2755A0D6A6AB19F746942 /* ConnectImportStepView.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 */; }; @@ -454,9 +464,6 @@ FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; }; - DD50C10A2F60A00000000001 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = DD50C10A2F60A00000000003 /* SocketIO */; }; - DD50C10A2F60B00000000002 /* NightscoutSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */; }; - DD50C10A2F60B00000000004 /* NightscoutSocketDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -491,13 +498,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBolusHistoryEntry.swift; sourceTree = ""; }; - B500000000000000000000A3 /* QuickPickBolusesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickBolusesManager.swift; sourceTree = ""; }; - B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMealHistoryEntry.swift; sourceTree = ""; }; - B500000000000000000000B3 /* QuickPickMealsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickMealsManager.swift; sourceTree = ""; }; 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; 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 = ""; }; + 201E9EC6620F89C9BC888A88 /* OverviewStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OverviewStepView.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 = ""; }; @@ -519,6 +523,7 @@ 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 = ""; }; + 3D4FF72ABE12982B49BA9FC1 /* TabOrderStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TabOrderStepView.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 = ""; }; @@ -556,6 +561,7 @@ 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 = ""; }; + 95B16E0A32D68AF70FC8C554 /* NotificationsStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsStepView.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 = ""; }; @@ -565,13 +571,21 @@ 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 = ""; }; + AEA2755A0D6A6AB19F746942 /* ConnectImportStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectImportStepView.swift; sourceTree = ""; }; B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AlarmsStepView.swift; sourceTree = ""; }; + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBolusHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000A3 /* QuickPickBolusesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickBolusesManager.swift; sourceTree = ""; }; + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMealHistoryEntry.swift; sourceTree = ""; }; + B500000000000000000000B3 /* QuickPickMealsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickMealsManager.swift; sourceTree = ""; }; + B500000000000000000000C1 /* QuickPickSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickSectionHeader.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 = ""; }; + BF1982C63D6BFEC44041F36C /* OnboardingNavFooter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingNavFooter.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 = ""; }; + DCEFB52C371A466BA960F91F /* GeneralAlarmsStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GeneralAlarmsStepView.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 = ""; }; @@ -604,7 +618,6 @@ DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; - B500000000000000000000C1 /* QuickPickSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickSectionHeader.swift; sourceTree = ""; }; DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = ""; }; @@ -634,8 +647,6 @@ DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = ""; }; DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = ""; }; DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = ""; }; - DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketManager.swift; sourceTree = ""; }; - DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketDataHandler.swift; sourceTree = ""; }; DD4A407D2E6AFEE6007B318B /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeOfDay.swift; sourceTree = ""; }; DD4AFB3C2DB55D2900BB593F /* AlarmConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmConfiguration.swift; sourceTree = ""; }; @@ -644,6 +655,8 @@ 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 = ""; }; + DD50C10A2F60B00000000001 /* NightscoutSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketManager.swift; sourceTree = ""; }; + DD50C10A2F60B00000000003 /* NightscoutSocketDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSocketDataHandler.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 = ""; }; @@ -793,6 +806,7 @@ 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 = ""; }; + E83384DB3CE9667D1E5EB05C /* TelemetryStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TelemetryStepView.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 = ""; }; @@ -1100,6 +1114,24 @@ path = Pods; sourceTree = ""; }; + B500000000000000000000A5 /* QuickPickBoluses */ = { + isa = PBXGroup; + children = ( + B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */, + B500000000000000000000A3 /* QuickPickBolusesManager.swift */, + ); + path = QuickPickBoluses; + sourceTree = ""; + }; + B500000000000000000000B5 /* QuickPickMeals */ = { + isa = PBXGroup; + children = ( + B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */, + B500000000000000000000B3 /* QuickPickMealsManager.swift */, + ); + path = QuickPickMeals; + sourceTree = ""; + }; BC3E3248623BA3A1C097F03A /* Steps */ = { isa = PBXGroup; children = ( @@ -1110,6 +1142,12 @@ 96A6FDE7D2E8EA86132A08B5 /* UnitsStepView.swift */, B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */, BB18D2D8AC7FCABCA3287745 /* CompletionStepView.swift */, + 201E9EC6620F89C9BC888A88 /* OverviewStepView.swift */, + DCEFB52C371A466BA960F91F /* GeneralAlarmsStepView.swift */, + 3D4FF72ABE12982B49BA9FC1 /* TabOrderStepView.swift */, + 95B16E0A32D68AF70FC8C554 /* NotificationsStepView.swift */, + E83384DB3CE9667D1E5EB05C /* TelemetryStepView.swift */, + AEA2755A0D6A6AB19F746942 /* ConnectImportStepView.swift */, ); name = Steps; path = Steps; @@ -1124,6 +1162,7 @@ E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */, 23FDA7658658AD4AE5A3D14B /* OnboardingStepHeader.swift */, 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */, + BF1982C63D6BFEC44041F36C /* OnboardingNavFooter.swift */, ); name = Onboarding; path = Onboarding; @@ -1170,24 +1209,6 @@ path = Metric; sourceTree = ""; }; - B500000000000000000000B5 /* QuickPickMeals */ = { - isa = PBXGroup; - children = ( - B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */, - B500000000000000000000B3 /* QuickPickMealsManager.swift */, - ); - path = QuickPickMeals; - sourceTree = ""; - }; - B500000000000000000000A5 /* QuickPickBoluses */ = { - isa = PBXGroup; - children = ( - B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */, - B500000000000000000000A3 /* QuickPickBolusesManager.swift */, - ); - path = QuickPickBoluses; - sourceTree = ""; - }; DD0C0C6E2C4AFFB800DBADDF /* Remote */ = { isa = PBXGroup; children = ( @@ -2569,6 +2590,13 @@ 8B5DC3C7CB15EFE705F89952 /* CompletionStepView.swift in Sources */, F957B82F8D80D5D762CDE068 /* LoopFollowLogo.swift in Sources */, F38017DF9689DF1F7639653A /* NotificationAuthorization.swift in Sources */, + 3290221F2E1B7C6A6BB9FF61 /* OverviewStepView.swift in Sources */, + 549729AB8E048848A064676D /* GeneralAlarmsStepView.swift in Sources */, + 15D060499FDE9355E7845378 /* TabOrderStepView.swift in Sources */, + 8A8F1C9AB629385666F7BA4C /* NotificationsStepView.swift in Sources */, + 7FEB5B1C5045A3DE20432E25 /* TelemetryStepView.swift in Sources */, + EAA0499272BE286A7E42BCAE /* ConnectImportStepView.swift in Sources */, + 048429FF512415FC9045CE14 /* OnboardingNavFooter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2979,20 +3007,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCVersionGroup section */ - FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { - isa = XCVersionGroup; - children = ( - FC3AE7B4249E8E0E00AAE1E0 /* LoopFollow.xcdatamodel */, - ); - currentVersion = FC3AE7B4249E8E0E00AAE1E0 /* LoopFollow.xcdatamodel */; - name = LoopFollow.xcdatamodeld; - path = LoopFollow/LoopFollow.xcdatamodeld; - sourceTree = ""; - versionGroupType = wrapper.xcdatamodel; - }; -/* End XCVersionGroup section */ - /* Begin XCRemoteSwiftPackageReference section */ DD50C10A2F60A00000000002 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */ = { isa = XCRemoteSwiftPackageReference; @@ -3011,6 +3025,20 @@ productName = SocketIO; }; /* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + FC3AE7B4249E8E0E00AAE1E0 /* LoopFollow.xcdatamodel */, + ); + currentVersion = FC3AE7B4249E8E0E00AAE1E0 /* LoopFollow.xcdatamodel */; + name = LoopFollow.xcdatamodeld; + path = LoopFollow/LoopFollow.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = FC97880C2485969B00A7906C /* Project object */; } diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index 15fb71c6d..69241c4b7 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -55,28 +55,48 @@ struct MainTabView: View { if !Storage.shared.hasCompletedOnboarding.value { showOnboarding = true } else { - presentTelemetryConsentIfNeeded() + runPostOnboardingPrompts() } } .fullScreenCover(isPresented: $showOnboarding, onDismiss: { - presentTelemetryConsentIfNeeded() + // Covers both finishing and skipping onboarding — the telemetry and + // notification steps live inside the flow, so anyone who skips still + // needs these handled here. + runPostOnboardingPrompts() }) { OnboardingContainerView(onClose: { showOnboarding = false }) } - .sheet(isPresented: $showTelemetryConsent) { + .sheet(isPresented: $showTelemetryConsent, onDismiss: { + // Ask for notifications only once telemetry is resolved, so the system + // prompt never stacks on top of the consent sheet. + requestNotificationsIfAlarmsEnabled() + }) { // User must explicitly choose — no swipe-to-dismiss. TelemetryConsentView() .interactiveDismissDisabled(true) } } - // 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() { + /// Runs after onboarding closes, whether it was completed or skipped. Telemetry + /// consent and notification permission both live inside the onboarding flow, so + /// a skip would otherwise bypass them. Telemetry consent goes first (as a + /// sheet); the notification request follows on its dismissal so the two never + /// appear at once. When the user completed the flow these are already decided, + /// so both calls are no-ops. + private func runPostOnboardingPrompts() { if !Storage.shared.telemetryConsentDecisionMade.value { - showTelemetryConsent = true + showTelemetryConsent = true // notifications requested on its dismiss + } else { + requestNotificationsIfAlarmsEnabled() + } + } + + /// Deferred-permission policy: only ask for notifications once there's an + /// enabled alarm that needs them. Safe to call repeatedly — it's a no-op once + /// the status is determined. + private func requestNotificationsIfAlarmsEnabled() { + if Storage.shared.alarms.value.contains(where: { $0.isEnabled }) { + NotificationAuthorization.requestIfNeeded() } } diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index cf698fbbe..096b225ea 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -466,11 +466,23 @@ class NightscoutUtils { let (data, response) = try await URLSession.shared.data(for: request) try validateProvisioningResponse(response) - let subject = try JSONDecoder().decode(AuthSubject.self, from: data) + // Nightscout returns the created subject wrapped in an array + // (`[{…}]`) on current versions, but a bare object on some older ones, + // so accept either shape. + let subject = try decodeCreatedSubject(from: data) guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown } return id } + private static func decodeCreatedSubject(from data: Data) throws -> AuthSubject { + let decoder = JSONDecoder() + if let array = try? decoder.decode([AuthSubject].self, from: data) { + guard let first = array.first else { throw NightscoutError.unknown } + return first + } + return try decoder.decode(AuthSubject.self, from: data) + } + /// Reproduces Nightscout's subject-token derivation (`lib/authorization`): /// abbrev = name lowercased, non-`\w` characters removed, first 10 chars /// digest = sha1( sha1Hex(apiSecret) + subjectId ) diff --git a/LoopFollow/Helpers/NotificationAuthorization.swift b/LoopFollow/Helpers/NotificationAuthorization.swift index f93d948c2..e8ed69afd 100644 --- a/LoopFollow/Helpers/NotificationAuthorization.swift +++ b/LoopFollow/Helpers/NotificationAuthorization.swift @@ -8,15 +8,22 @@ import UserNotifications /// 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() { + /// repeatedly — it's a no-op once the status is determined. `completion` runs + /// on the main queue after the prompt is dismissed (or immediately when the + /// status was already determined), so a caller can wait for the system prompt + /// before moving on. + static func requestIfNeeded(completion: @escaping () -> Void = {}) { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in - guard settings.authorizationStatus == .notDetermined else { return } + guard settings.authorizationStatus == .notDetermined else { + DispatchQueue.main.async { completion() } + return + } center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in if !granted { LogManager.shared.log(category: .general, message: "User has declined notifications") } + DispatchQueue.main.async { completion() } } } } diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index 26246a728..afd90117f 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -348,7 +348,7 @@ struct TelemetryConsentView: View { VStack(alignment: .leading, spacing: 16) { Text("You can choose to share anonymous information with the developers to help improve LoopFollow—such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.") - Text("You can change this any time in Settings → Diagnostics.") + Text("You can change this any time in Settings → General → Diagnostics.") .font(.subheadline) .foregroundColor(.secondary) diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 559a12916..7693e3ac9 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -35,6 +35,29 @@ class NightscoutSettingsViewModel: ObservableObject { @Published var nightscoutStatus: String = "Checking..." + /// The most recent verification error, kept so the onboarding address page can + /// tell "reachable Nightscout that needs a token" apart from "can't reach it". + @Published var lastError: NightscoutUtils.NightscoutError? + + /// True when the most recent error means the site is a reachable Nightscout + /// that simply needs (a different) token. + private var errorIsTokenRelated: Bool { + switch lastError { + case .tokenRequired, .invalidToken: return true + default: return false + } + } + + /// The site responded as a Nightscout instance, even if it needs a token. + var addressReachable: Bool { + isConnected || errorIsTokenRelated + } + + /// The site is reachable but requires a token we don't have yet. + var addressNeedsToken: Bool { + !isConnected && errorIsTokenRelated + } + @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value { didSet { Storage.shared.webSocketEnabled.value = webSocketEnabled @@ -62,6 +85,19 @@ class NightscoutSettingsViewModel: ObservableObject { private var checkStatusSubject = PassthroughSubject() private var checkStatusWorkItem: DispatchWorkItem? + /// While confirming a freshly provisioned token, the retry loop owns the + /// status label, so the ordinary debounced check is suppressed to avoid + /// flickering "Invalid Token" before the server has caught up. + private var isConfirmingProvisionedToken = false + + /// Set when a token we just created is correct (the create call returned its + /// id, so the secret was valid and the token is a deterministic function of + /// it) but the site hasn't started accepting it yet. Some hosts only reload + /// their auth cache on a restart, which can take minutes — far longer than we + /// can spin during onboarding — so this is treated as a success-pending state + /// the user can proceed from, not an error. + @Published private(set) var provisionedTokenPending = false + init() { initialURL = Storage.shared.url.value initialToken = Storage.shared.token.value @@ -84,6 +120,8 @@ class NightscoutSettingsViewModel: ObservableObject { private func triggerCheckStatus() { checkStatusWorkItem?.cancel() + // Any manual edit invalidates a pending-provisioned state. + provisionedTokenPending = false nightscoutStatus = "Checking..." checkStatusWorkItem = DispatchWorkItem { @@ -121,6 +159,7 @@ class NightscoutSettingsViewModel: ObservableObject { } func checkNightscoutStatus() { + if isConfirmingProvisionedToken { return } NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in DispatchQueue.main.async { Storage.shared.nsWriteAuth.value = nsWriteAuth @@ -131,7 +170,54 @@ class NightscoutSettingsViewModel: ObservableObject { } } + /// Applies a token that LoopFollow just created and confirms it works. + /// + /// A freshly created subject isn't always recognized immediately: each + /// Nightscout server instance only reloads its in-memory subject cache on a + /// write, and multi-instance deployments don't share that cache — so the + /// first validation can be routed to an instance that hasn't caught up yet. + /// Rather than fail (and make the user tap again), we poll for a few seconds + /// with a reassuring status before surfacing any error. + func confirmProvisionedToken(_ token: String) { + isConfirmingProvisionedToken = true + provisionedTokenPending = false + isConnected = false + nightscoutStatus = "Finishing connection…" + nightscoutToken = token + verifyProvisionedTokenLoop(attempt: 0) + } + + private func verifyProvisionedTokenLoop(attempt: Int) { + let maxAttempts = 8 + NightscoutUtils.verifyURLAndToken { [weak self] error, _, nsWriteAuth, nsAdminAuth in + DispatchQueue.main.async { + guard let self else { return } + if error == nil { + self.isConfirmingProvisionedToken = false + self.provisionedTokenPending = false + Storage.shared.nsWriteAuth.value = nsWriteAuth + Storage.shared.nsAdminAuth.value = nsAdminAuth + self.updateStatusLabel(error: nil) + } else if attempt + 1 < maxAttempts { + self.nightscoutStatus = "Finishing connection…" + let delay = min(0.5 + Double(attempt) * 0.25, 2.0) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.verifyProvisionedTokenLoop(attempt: attempt + 1) + } + } else { + // The token is correct but the site hasn't started accepting + // it yet. Surface a calm "pending" state the user can proceed + // from rather than a red error. + self.isConfirmingProvisionedToken = false + self.provisionedTokenPending = true + self.lastError = nil + } + } + } + } + func updateStatusLabel(error: NightscoutUtils.NightscoutError?) { + lastError = error if let error = error { isConnected = false switch error { @@ -172,6 +258,52 @@ class NightscoutSettingsViewModel: ObservableObject { NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) } + // MARK: - Adaptive status (onboarding) + + enum ConnectionStatusKind { + case idle + case checking + case needsToken + case pending + case connected + case error + } + + /// A coarse status used to drive the onboarding status pill's color and icon. + var statusKind: ConnectionStatusKind { + if isConfirmingProvisionedToken { return .checking } + if nightscoutURL.isEmpty { return .idle } + if isConnected { return .connected } + // Token created and correct, just not accepted by the site yet. + if provisionedTokenPending { return .pending } + if nightscoutStatus == "Checking..." { return .checking } + // The site is reachable and simply needs a token — that's an expected + // step, not an error, so it's shown positively rather than red. + if addressNeedsToken { return .needsToken } + return .error + } + + /// A friendly, contextual status line that updates as the user fills fields, + /// rather than a fixed "Status" label that can read as stale. + var friendlyStatus: String { + switch statusKind { + case .idle: + return "Enter your site address to connect." + case .checking: + return isConfirmingProvisionedToken ? "Finishing connection…" : "Checking your connection…" + case .needsToken: + return "Site found — it needs a token." + case .pending: + return "Token created. Your site can take a few minutes to start accepting it — you can continue." + case .connected: + if Storage.shared.nsAdminAuth.value { return "Connected — admin access" } + if Storage.shared.nsWriteAuth.value { return "Connected — read & write" } + return "Connected — read-only" + case .error: + return nightscoutStatus + } + } + private func observeWebSocketState() { updateWebSocketStatus() NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged) diff --git a/LoopFollow/Onboarding/OnboardingContainerView.swift b/LoopFollow/Onboarding/OnboardingContainerView.swift index a53168c58..e15cf5cd7 100644 --- a/LoopFollow/Onboarding/OnboardingContainerView.swift +++ b/LoopFollow/Onboarding/OnboardingContainerView.swift @@ -15,14 +15,14 @@ struct OnboardingContainerView: View { var body: some View { VStack(spacing: 0) { - if viewModel.step.showsChrome { + if viewModel.step.showsProgressHeader { header } stepContent .frame(maxWidth: .infinity, maxHeight: .infinity) - if viewModel.step.showsChrome { + if viewModel.step.usesSharedFooter { footer } } @@ -33,11 +33,20 @@ struct OnboardingContainerView: View { // MARK: - Chrome private var header: some View { - HStack(spacing: 12) { - OnboardingProgressBar(progress: viewModel.progress) - Button("Skip") { viewModel.skip() } - .font(.subheadline) - .foregroundColor(.secondary) + VStack(spacing: 6) { + HStack(spacing: 12) { + OnboardingProgressBar(progress: viewModel.progress) + Button("Skip") { viewModel.skip() } + .font(.subheadline) + .foregroundColor(.secondary) + } + + if !viewModel.progressLabel.isEmpty { + Text(viewModel.progressLabel) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } } .padding(.horizontal) .padding(.top, 12) @@ -50,19 +59,31 @@ struct OnboardingContainerView: View { switch viewModel.step { case .welcome: WelcomeStepView(viewModel: viewModel) + case .overview: + OverviewStepView() case .dataSource: DataSourceChoiceStepView(viewModel: viewModel) case .connect: switch viewModel.dataSource { case .dexcom: - DexcomConnectStepView(viewModel: viewModel.dexcomViewModel) + DexcomConnectStepView(viewModel: viewModel.dexcomViewModel, onboarding: viewModel) + case .copyFromPhone: + ConnectImportStepView(viewModel: viewModel) default: - NightscoutConnectStepView(viewModel: viewModel.nightscoutViewModel) + NightscoutConnectStepView(viewModel: viewModel.nightscoutViewModel, onboarding: viewModel) } case .units: UnitsStepView() + case .generalAlarms: + GeneralAlarmsStepView() case .alarms: AlarmsStepView(viewModel: viewModel) + case .tabOrder: + TabOrderStepView() + case .notifications: + NotificationsStepView(viewModel: viewModel) + case .telemetry: + TelemetryStepView(viewModel: viewModel) case .completion: CompletionStepView(viewModel: viewModel) } @@ -81,31 +102,12 @@ struct OnboardingContainerView: View { } 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) + OnboardingNavFooter( + continueEnabled: viewModel.canProceed, + showBack: viewModel.canGoBack, + onBack: { withStepAnimation { viewModel.goBack() } }, + onContinue: { withStepAnimation { viewModel.advance() } } + ) } private func withStepAnimation(_ change: () -> Void) { diff --git a/LoopFollow/Onboarding/OnboardingNavFooter.swift b/LoopFollow/Onboarding/OnboardingNavFooter.swift new file mode 100644 index 000000000..1b0f3e2d4 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingNavFooter.swift @@ -0,0 +1,39 @@ +// LoopFollow +// OnboardingNavFooter.swift + +import SwiftUI + +/// The shared Back / Continue footer used both by the container's chrome and by +/// phases that manage their own internal pages (connect, alarms), so the controls +/// look and behave identically everywhere. +struct OnboardingNavFooter: View { + var continueTitle: String = "Continue" + var continueEnabled: Bool = true + var showBack: Bool = true + var onBack: () -> Void + var onContinue: () -> Void + + var body: some View { + HStack { + if showBack { + Button(action: onBack) { + Label("Back", systemImage: "chevron.left") + .font(.body.weight(.medium)) + } + .buttonStyle(.bordered) + } + + Spacer() + + Button(action: onContinue) { + Text(continueTitle) + .font(.body.weight(.semibold)) + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(!continueEnabled) + } + .padding() + .background(.bar) + } +} diff --git a/LoopFollow/Onboarding/OnboardingStep.swift b/LoopFollow/Onboarding/OnboardingStep.swift index 90a20f6b9..39a859d8d 100644 --- a/LoopFollow/Onboarding/OnboardingStep.swift +++ b/LoopFollow/Onboarding/OnboardingStep.swift @@ -3,35 +3,67 @@ import Foundation -/// The ordered steps of the first-run onboarding wizard. +/// The phases 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 { +/// Progress is tracked by phase, not by page: the set of phases is stable, while +/// some phases contain a variable number of internal pages (the Nightscout connect +/// phase can be one or two pages; the alarms phase is one page per offered alarm, +/// which differs between Nightscout and Dexcom). Those phases report their +/// within-phase position through `OnboardingViewModel.phaseProgress`, so the +/// overall progress bar stays smooth no matter how many pages a phase has. +/// +/// `connect` renders the Nightscout, Dexcom, or import view depending on the data +/// source the user picks in `dataSource`. +enum OnboardingStep: CaseIterable, Hashable { case welcome + case overview case dataSource case connect case units + case generalAlarms case alarms + case tabOrder + case notifications + case telemetry case completion - var next: OnboardingStep? { - OnboardingStep(rawValue: rawValue + 1) + /// Steps that show the progress bar + phase label + Skip at the top. The + /// welcome and completion screens are full-bleed and provide their own CTA. + var showsProgressHeader: Bool { + switch self { + case .welcome, .completion: + return false + default: + return true + } } - var previous: OnboardingStep? { - OnboardingStep(rawValue: rawValue - 1) + /// Steps that use the shared Back / Continue footer. Phases that contain their + /// own internal pages or custom primary buttons (connect, alarms, notifications, + /// telemetry) supply their own footer instead, as do the full-bleed welcome and + /// completion screens. + var usesSharedFooter: Bool { + switch self { + case .overview, .dataSource, .units, .generalAlarms, .tabOrder: + return true + default: + return false + } } - /// 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 { + /// Short name shown in the progress header for this phase. + var phaseTitle: String { switch self { - case .welcome, .completion: - return false - case .dataSource, .connect, .units, .alarms: - return true + case .welcome, .completion: return "" + case .overview: return "Overview" + case .dataSource: return "Data source" + case .connect: return "Connect" + case .units: return "Units & metrics" + case .generalAlarms: return "Alarm basics" + case .alarms: return "Alarms" + case .tabOrder: return "Tabs" + case .notifications: return "Notifications" + case .telemetry: return "Privacy" } } } diff --git a/LoopFollow/Onboarding/OnboardingStepHeader.swift b/LoopFollow/Onboarding/OnboardingStepHeader.swift index cabc416a5..8b773dbf2 100644 --- a/LoopFollow/Onboarding/OnboardingStepHeader.swift +++ b/LoopFollow/Onboarding/OnboardingStepHeader.swift @@ -10,7 +10,9 @@ struct OnboardingStepHeader: View { let subtitle: String var body: some View { - VStack(spacing: 12) { + // Left-aligned: justified/centered body copy is harder to read, so the + // header reads as a natural top-down intro. + VStack(alignment: .leading, spacing: 12) { Image(systemName: systemImage) .font(.system(size: 44, weight: .semibold)) .foregroundStyle(Color.accentColor) @@ -18,14 +20,14 @@ struct OnboardingStepHeader: View { Text(title) .font(.title2.weight(.bold)) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) Text(subtitle) .font(.subheadline) .foregroundColor(.secondary) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) .padding(.top, 8) } diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift index 4ac25c5ed..198f68e95 100644 --- a/LoopFollow/Onboarding/OnboardingViewModel.swift +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -3,8 +3,9 @@ import Combine import SwiftUI +import UserNotifications -/// Drives the onboarding wizard: tracks the current step, the chosen data +/// Drives the onboarding wizard: tracks the current phase, 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 @@ -15,21 +16,53 @@ final class OnboardingViewModel: ObservableObject { enum DataSource: Hashable { case nightscout case dexcom + case copyFromPhone } - /// A single default alarm offered on the alarms step. + /// A single recommended alarm offered on the alarms phase. Display fields are + /// carried explicitly (rather than derived from `AlarmType`) so two alarms of + /// the same type — a warning Low and an Urgent Low — can read differently. struct SeedAlarm: Identifiable { let id = UUID() var alarm: Alarm - var isEnabled: Bool = true + var isEnabled: Bool + var title: String + var detail: String + /// Needs Nightscout loop/uploader data, so it's hidden for Dexcom-only. + var requiresNightscout: Bool var type: AlarmType { alarm.type } + + /// Use each alarm type's own icon rather than a bespoke one. + var icon: String { alarm.type.icon } + } + + /// Position within a multi-page phase, reported by that phase's view so the + /// overall progress bar and label can reflect it. + struct PhaseProgress: Equatable { + var page: Int + var count: Int } @Published var step: OnboardingStep = .welcome @Published var dataSource: DataSource? @Published var seedAlarms: [SeedAlarm] + /// Within-phase position for phases that own internal pages (connect, alarms). + /// Reset to `nil` whenever the phase changes. + @Published var phaseProgress: PhaseProgress? + + /// Set once a QR settings import on the "copy from another phone" path + /// succeeds, so the connect phase can be considered complete. + @Published var didImportSettings = false + + /// Whether the notification permission is still undecided. The notifications + /// phase is only shown when it is — there's no point prompting someone who has + /// already granted or denied it (iOS won't show the prompt again anyway). + /// Defaults to `true`; resolved asynchronously at launch, well before the user + /// reaches that phase. + @Published private var notificationsUndecided = true + let nightscoutViewModel = NightscoutSettingsViewModel() let dexcomViewModel = DexcomSettingsViewModel() @@ -37,10 +70,22 @@ final class OnboardingViewModel: ObservableObject { private let onClose: () -> Void private var cancellables = Set() + /// Whether to show the in-flow telemetry consent phase. Captured once at + /// launch so the decision the phase records doesn't remove it from under the + /// navigation while the user is still on it. + private let includeTelemetryStep: Bool + + /// Alarm types the user already has, captured at launch. Onboarding only + /// offers recommended alarms whose type the user doesn't already own, so a + /// returning user is helped to add new ones without touching their existing + /// (possibly custom-named) alarms. + private let existingAlarmTypes: Set + init(onClose: @escaping () -> Void) { self.onClose = onClose - seedAlarms = [.low, .high, .missedReading, .notLooping, .battery] - .map { SeedAlarm(alarm: Alarm(type: $0)) } + includeTelemetryStep = !Storage.shared.telemetryConsentDecisionMade.value + existingAlarmTypes = Set(Storage.shared.alarms.value.map(\.type)) + seedAlarms = OnboardingViewModel.defaultSeedAlarms() // Re-publish child changes so the footer's `canProceed` stays in sync // with live connection validation. @@ -50,6 +95,13 @@ final class OnboardingViewModel: ObservableObject { dexcomViewModel.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) + + UNUserNotificationCenter.current().getNotificationSettings { settings in + let undecided = settings.authorizationStatus == .notDetermined + Task { @MainActor [weak self] in + self?.notificationsUndecided = undecided + } + } } // MARK: - Derived state @@ -65,48 +117,113 @@ final class OnboardingViewModel: ObservableObject { var canProceed: Bool { switch step { - case .welcome, .units, .alarms, .completion: + case .welcome, .overview, .units, .generalAlarms, .alarms, + .tabOrder, .notifications, .telemetry, .completion: return true case .dataSource: return dataSource != nil case .connect: switch dataSource { - case .nightscout: return nightscoutViewModel.isConnected - case .dexcom: return dexcomViewModel.hasCredentials + case .nightscout: return nightscoutViewModel.isConnected || nightscoutViewModel.provisionedTokenPending + case .dexcom: return dexcomViewModel.canVerifyProceed + case .copyFromPhone: return didImportSettings case .none: return false } } } - /// 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 + /// Whether Nightscout loop/uploader data is (or will be) available — used to + /// gate alarms that depend on it. True for the Nightscout source, or any path + /// that ends with a Nightscout URL configured (including a QR import). + private var hasNightscoutData: Bool { + dataSource == .nightscout || !Storage.shared.url.value.isEmpty } - /// Progress fraction (0...1) across the chrome'd steps, for the progress bar. + /// Whether a seeded alarm should be offered: its data source must be available + /// and the user must not already have an alarm of that type. + func isOffered(_ seed: SeedAlarm) -> Bool { + guard !seed.requiresNightscout || hasNightscoutData else { return false } + return !existingAlarmTypes.contains(seed.type) + } + + var offeredSeedAlarms: [SeedAlarm] { + seedAlarms.filter { isOffered($0) } + } + + private var alarmsOffered: Bool { !offeredSeedAlarms.isEmpty } + + /// The phases actually shown for the current configuration, in order. Optional + /// phases are included only when relevant. + var activeSteps: [OnboardingStep] { + var steps: [OnboardingStep] = [.welcome, .overview, .dataSource, .connect, .units] + if alarmsOffered { + steps.append(contentsOf: [.generalAlarms, .alarms]) + } + steps.append(.tabOrder) + if notificationsUndecided { + steps.append(.notifications) + } + if includeTelemetryStep { + steps.append(.telemetry) + } + steps.append(.completion) + return steps + } + + /// Phases that show the progress header — the unit over which the bar fills. + private var chromePhases: [OnboardingStep] { + activeSteps.filter { $0.showsProgressHeader } + } + + /// Progress fraction (0...1). Each phase is one slot; a multi-page phase fills + /// its slot proportionally via `phaseProgress`, so the bar stays smooth no + /// matter how many pages a phase turns out to have. var progress: Double { - let total = Double(OnboardingStep.allCases.count - 1) - guard total > 0 else { return 0 } - return Double(step.rawValue) / total + guard !chromePhases.isEmpty else { return 0 } + guard let index = chromePhases.firstIndex(of: step) else { + return step == .completion ? 1 : 0 + } + let within: Double + if let progress = phaseProgress, progress.count > 1 { + within = Double(progress.page) / Double(progress.count) + } else { + within = 0 + } + return (Double(index) + within) / Double(chromePhases.count) + } + + /// The phase name shown in the header, with a local page count for multi-page + /// phases (e.g. "Alarms · 3 of 13"). + var progressLabel: String { + let title = step.phaseTitle + if let progress = phaseProgress, progress.count > 1 { + return "\(title) · \(min(progress.page + 1, progress.count)) of \(progress.count)" + } + return title } // MARK: - Navigation + var canGoBack: Bool { + guard let index = activeSteps.firstIndex(of: step) else { return false } + return index > 0 + } + func advance() { - guard let next = step.next else { + phaseProgress = nil + guard let index = activeSteps.firstIndex(of: step), + index + 1 < activeSteps.count + else { finish() return } - step = next + step = activeSteps[index + 1] } func goBack() { - guard let previous = step.previous else { return } - step = previous + phaseProgress = nil + guard let index = activeSteps.firstIndex(of: step), index > 0 else { return } + step = activeSteps[index - 1] } /// Skip the rest of setup. Marks onboarding complete without seeding alarms @@ -132,11 +249,95 @@ final class OnboardingViewModel: ObservableObject { var alarms = Storage.shared.alarms.value let existingTypes = Set(alarms.map(\.type)) - for seed in seedAlarms where seed.isEnabled && isSeedAlarmOffered(seed.type) { + for seed in offeredSeedAlarms where seed.isEnabled { + // Don't re-add a type the user already configured. The two seeded Low + // alarms (warning + urgent) are both added on a fresh install because + // `existingTypes` is sampled once, before any are appended. guard !existingTypes.contains(seed.type) else { continue } alarms.append(seed.alarm) } Storage.shared.alarms.value = alarms } + + // MARK: - Default seed set + + /// The recommended alarms, in page order. Glucose and phone alarms are offered + /// to everyone; loop/pump/insulin alarms require Nightscout data. + private static func defaultSeedAlarms() -> [SeedAlarm] { + func seed( + _ type: AlarmType, + title: String, + detail: String, + requiresNightscout: Bool, + configure: (inout Alarm) -> Void = { _ in } + ) -> SeedAlarm { + var alarm = Alarm(type: type) + configure(&alarm) + return SeedAlarm( + alarm: alarm, + isEnabled: true, + title: title, + detail: detail, + requiresNightscout: requiresNightscout + ) + } + + return [ + seed(.low, title: "Low glucose", + detail: "Warns when glucose is low, now or soon.", + requiresNightscout: false) + { + $0.belowBG = 80 + $0.predictiveMinutes = 15 + }, + seed(.low, title: "Urgent low", + detail: "A separate warning for when glucose is very low.", + requiresNightscout: false) + { + $0.belowBG = 55 + $0.predictiveMinutes = 0 + $0.persistentMinutes = 0 + }, + seed(.high, title: "High glucose", + detail: "Warns when glucose is high.", + requiresNightscout: false) + { + $0.aboveBG = 180 + }, + seed(.fastDrop, title: "Fast drop", + detail: "Warns when glucose is falling quickly.", + requiresNightscout: false), + seed(.missedReading, title: "Missed readings", + detail: "Warns when glucose stops updating.", + requiresNightscout: false), + seed(.notLooping, title: "Not looping", + detail: "Warns when the loop stops running.", + requiresNightscout: true), + seed(.battery, title: "Looping phone battery", + detail: "Warns when the battery of the phone running Loop or Trio is low.", + requiresNightscout: true), + seed(.iob, title: "IOB", + detail: "Warns when insulin on board is high.", + requiresNightscout: true), + seed(.cob, title: "COB", + detail: "Warns when carbs on board are high.", + requiresNightscout: true), + seed(.sensorChange, title: "Sensor change", + detail: "Reminds you when the CGM sensor is due.", + requiresNightscout: true) + { + $0.threshold = 10 + }, + seed(.pumpChange, title: "Pump change", + detail: "Reminds you when the pump site is due.", + requiresNightscout: true) + { + $0.threshold = 3 + }, + seed(.pump, title: "Pump insulin", + detail: "Warns when pump reservoir insulin is low.", + requiresNightscout: true), + ] + } } diff --git a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift index b987dc547..4b0804f8c 100644 --- a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift +++ b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift @@ -3,19 +3,79 @@ import SwiftUI +/// The alarms phase: one recommended alarm per page, each with a toggle and a few +/// of its most useful settings. The number of pages depends on the data source +/// (Dexcom-only followers don't see loop/pump alarms), which the phase reports via +/// `phaseProgress` so the overall progress bar stays accurate. struct AlarmsStepView: View { @ObservedObject var viewModel: OnboardingViewModel + /// Page index into `offeredIndices`. + @State private var index = 0 + + /// Indices into `viewModel.seedAlarms` that are offered for this data source. + private var offeredIndices: [Int] { + viewModel.seedAlarms.indices.filter { viewModel.isOffered(viewModel.seedAlarms[$0]) } + } + var body: some View { - Form { + VStack(spacing: 0) { + if let seedIndex = offeredIndices[safe: index] { + alarmPage(seedIndex: seedIndex) + } + + OnboardingNavFooter( + continueEnabled: true, + showBack: true, + onBack: goBack, + onContinue: goForward + ) + } + .onAppear(perform: reportProgress) + .onChange(of: index) { _ in reportProgress() } + } + + private func reportProgress() { + viewModel.phaseProgress = .init(page: index, count: offeredIndices.count) + } + + private func goForward() { + if index < offeredIndices.count - 1 { + withAnimation(.easeInOut(duration: 0.25)) { index += 1 } + } else { + viewModel.advance() + } + } + + private func goBack() { + if index > 0 { + withAnimation(.easeInOut(duration: 0.25)) { index -= 1 } + } else { + viewModel.goBack() + } + } + + // MARK: - Page + + private func alarmPage(seedIndex: Int) -> some View { + let seed = $viewModel.seedAlarms[seedIndex] + let display = viewModel.seedAlarms[seedIndex] + return Form { Section { EmptyView() } header: { - OnboardingStepHeader( - systemImage: "bell.badge.fill", - title: "Useful alarms", - subtitle: "We'll set up a few commonly used alarms with sensible defaults. Turn off any you don't want and adjust the rest." - ) + VStack(spacing: 8) { + Image(systemName: display.icon) + .font(.system(size: 40, weight: .semibold)) + .foregroundStyle(Color.accentColor) + Text(display.title) + .font(.title2.weight(.bold)) + Text(display.detail) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) .textCase(nil) .padding(.bottom, 8) } @@ -23,79 +83,101 @@ struct AlarmsStepView: View { .listRowBackground(Color.clear) Section { - ForEach($viewModel.seedAlarms) { $seed in - 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) - } - } - - if seed.isEnabled { - control(for: $seed) - } - } + Toggle("Enable this alarm", isOn: seed.isEnabled) + + if seed.wrappedValue.isEnabled { + controls(for: seed) } - } footer: { - Text("These come with sensible defaults — fine-tune them any time in the Alarms tab.") } } } - // MARK: - Per-alarm control + // MARK: - Per-alarm controls @ViewBuilder - private func control(for seed: Binding) -> some View { + private func controls(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) - ) + bgPicker(seed, title: "Alert below", range: 40 ... 150, keyPath: \.belowBG, default: 80) + intStepper(seed, label: "Warn early by", range: 0 ... 30, step: 5, unit: "min", keyPath: \.predictiveMinutes, default: 0) case .high: - BGPicker( - title: "Alert above", - range: 120 ... 350, - value: doubleBinding(seed, keyPath: \.aboveBG, default: 180) - ) + bgPicker(seed, title: "Alert above", range: 120 ... 350, keyPath: \.aboveBG, default: 180) + intStepper(seed, label: "Only after high for", range: 0 ... 60, step: 5, unit: "min", keyPath: \.persistentMinutes, default: 0) + case .fastDrop: + doubleStepper(seed, label: "Drop of at least", range: 5 ... 50, step: 1, unit: "mg/dL", keyPath: \.delta, default: 18) case .missedReading: - stepperRow(seed, label: "No reading for", range: 11 ... 121, step: 5, unit: "min", default: 16) + doubleStepper(seed, label: "No reading for", range: 11 ... 121, step: 5, unit: "min", keyPath: \.threshold, default: 16) case .notLooping: - stepperRow(seed, label: "No loop for", range: 16 ... 61, step: 5, unit: "min", default: 31) + doubleStepper(seed, label: "No loop for", range: 16 ... 61, step: 5, unit: "min", keyPath: \.threshold, default: 31) case .battery: - stepperRow(seed, label: "At or below", range: 0 ... 100, step: 5, unit: "%", default: 20) + doubleStepper(seed, label: "At or below", range: 0 ... 100, step: 5, unit: "%", keyPath: \.threshold, default: 20) + case .iob: + doubleStepper(seed, label: "Alert above", range: 0 ... 30, step: 1, unit: "U", keyPath: \.threshold, default: 6) + case .cob: + doubleStepper(seed, label: "Alert above", range: 0 ... 200, step: 5, unit: "g", keyPath: \.threshold, default: 20) + case .sensorChange: + doubleStepper(seed, label: "Remind after", range: 1 ... 15, step: 1, unit: "days", keyPath: \.threshold, default: 10) + case .pumpChange: + doubleStepper(seed, label: "Remind after", range: 1 ... 7, step: 1, unit: "days", keyPath: \.threshold, default: 3) + case .pump: + doubleStepper(seed, label: "Alert below", range: 0 ... 100, step: 5, unit: "U", keyPath: \.threshold, default: 10) default: EmptyView() } } - private func stepperRow( + private func bgPicker( + _ seed: Binding, + title: String, + range: ClosedRange, + keyPath: WritableKeyPath, + default def: Double + ) -> some View { + BGPicker(title: title, range: range, value: doubleBinding(seed, keyPath: keyPath, default: def)) + } + + private func doubleStepper( _ seed: Binding, label: String, range: ClosedRange, step: Double, unit: String, + keyPath: WritableKeyPath, default def: Double ) -> some View { - let value = doubleBinding(seed, keyPath: \.threshold, default: def) + let value = doubleBinding(seed, keyPath: keyPath, default: def) return Stepper(value: value, in: range, step: step) { - HStack { - Text(label) - Spacer() - Text("\(Int(value.wrappedValue)) \(unit)") - .foregroundColor(.secondary) - } + labelRow(label, value: "\(formatted(value.wrappedValue)) \(unit)") } } + private func intStepper( + _ seed: Binding, + label: String, + range: ClosedRange, + step: Int, + unit: String, + keyPath: WritableKeyPath, + default def: Int + ) -> some View { + let value = intBinding(seed, keyPath: keyPath, default: def) + return Stepper(value: value, in: range, step: step) { + labelRow(label, value: "\(value.wrappedValue) \(unit)") + } + } + + private func labelRow(_ label: String, value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value).foregroundColor(.secondary) + } + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(format: "%.1f", value) + } + private func doubleBinding( _ seed: Binding, keyPath: WritableKeyPath, @@ -107,28 +189,20 @@ struct AlarmsStepView: View { ) } - // MARK: - Copy - - private struct AlarmMeta { - let title: String - let detail: String - let icon: String + private func intBinding( + _ seed: Binding, + keyPath: WritableKeyPath, + default def: Int + ) -> Binding { + Binding( + get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? def }, + set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 } + ) } +} - 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") - } +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/LoopFollow/Onboarding/Steps/CompletionStepView.swift b/LoopFollow/Onboarding/Steps/CompletionStepView.swift index 4c6a09ce5..8f953c160 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("You're 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.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) @@ -33,7 +33,7 @@ struct CompletionStepView: View { Spacer() Button { viewModel.finish() } label: { - Text("Done") + Text("Finish") .font(.body.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 4) @@ -44,13 +44,6 @@ struct CompletionStepView: View { } .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/ConnectImportStepView.swift b/LoopFollow/Onboarding/Steps/ConnectImportStepView.swift new file mode 100644 index 000000000..ef25b59ae --- /dev/null +++ b/LoopFollow/Onboarding/Steps/ConnectImportStepView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// ConnectImportStepView.swift + +import SwiftUI + +/// The "copy from another phone" connect path: scan a QR code exported from an +/// already-configured LoopFollow and apply those settings. Reuses the same +/// scanner, preview, and import logic as Settings → Import/Export. +struct ConnectImportStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @StateObject private var importVM = ImportExportSettingsViewModel() + + private var importSucceeded: Bool { + importVM.qrCodeErrorMessage.contains("Successfully imported") + } + + var body: some View { + VStack(spacing: 0) { + form + OnboardingNavFooter( + continueEnabled: viewModel.didImportSettings, + showBack: viewModel.canGoBack, + onBack: { viewModel.goBack() }, + onContinue: { viewModel.advance() } + ) + } + } + + private var form: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "qrcode", + title: "Copy from another phone", + subtitle: "On your other phone, open Settings → Nightscout (or Dexcom Share) → Import/Export and export to a QR code. Then scan it here." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + Button { + importVM.isShowingQRCodeScanner = true + } label: { + HStack { + Image(systemName: "qrcode.viewfinder") + .foregroundColor(.accentColor) + Text(viewModel.didImportSettings ? "Scan a different code" : "Scan QR Code") + } + } + } + + if viewModel.didImportSettings { + Section { + Label("Settings imported — you're connected.", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } + } else if !importVM.qrCodeErrorMessage.isEmpty { + Section { + Text(importVM.qrCodeErrorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + } + .sheet(isPresented: $importVM.isShowingQRCodeScanner) { + SimpleQRCodeScannerView { result in + importVM.handleQRCodeScanResult(result) + } + } + .sheet(isPresented: $importVM.showImportConfirmation) { + ImportConfirmationView(viewModel: importVM) + } + .onChange(of: importVM.qrCodeErrorMessage) { message in + if message.contains("Successfully imported") { + viewModel.didImportSettings = true + } + } + } +} diff --git a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift index 6192b742f..b94abea15 100644 --- a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift +++ b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift @@ -24,9 +24,15 @@ struct DataSourceChoiceStepView: View { ) choiceCard( source: .dexcom, - icon: "drop.fill", + icon: "sensor.tag.radiowaves.forward.fill", title: "Dexcom Share", - detail: "Follow glucose directly from a Dexcom Share account. Simplest option when there's no Nightscout site." + detail: "Follow glucose directly from a Dexcom Share account, without a Nightscout site." + ) + choiceCard( + source: .copyFromPhone, + icon: "qrcode", + title: "Copy from another phone", + detail: "Already using LoopFollow on another phone? Scan its QR code to copy the connection here." ) } .padding(.horizontal) diff --git a/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift index 14680c01b..f9203d614 100644 --- a/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift +++ b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift @@ -5,14 +5,27 @@ import SwiftUI struct DexcomConnectStepView: View { @ObservedObject var viewModel: DexcomSettingsViewModel + @ObservedObject var onboarding: OnboardingViewModel var body: some View { + VStack(spacing: 0) { + form + OnboardingNavFooter( + continueEnabled: viewModel.canVerifyProceed, + showBack: onboarding.canGoBack, + onBack: { onboarding.goBack() }, + onContinue: { onboarding.advance() } + ) + } + } + + private var form: some View { Form { Section { EmptyView() } header: { OnboardingStepHeader( - systemImage: "drop.fill", + systemImage: "sensor.tag.radiowaves.forward.fill", title: "Connect Dexcom Share", subtitle: "Sign in with the Dexcom Share account that shares glucose data." ) @@ -26,6 +39,7 @@ struct DexcomConnectStepView: View { HStack { Text("Username") TextField("Enter Username", text: $viewModel.userName) + .textContentType(.username) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) @@ -36,7 +50,8 @@ struct DexcomConnectStepView: View { TogglableSecureInput( placeholder: "Enter Password", text: $viewModel.password, - style: .singleLine + style: .singleLine, + textContentType: .password ) } @@ -48,13 +63,27 @@ struct DexcomConnectStepView: View { } 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") + HStack(spacing: 10) { + statusIcon + .frame(width: 20) + Text(viewModel.statusMessage) .foregroundColor(.secondary) } } } } + + @ViewBuilder + private var statusIcon: some View { + switch viewModel.statusKind { + case .idle: + Image(systemName: "circle").foregroundColor(.secondary) + case .checking: + ProgressView() + case .connected: + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + case .error: + Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.red) + } + } } diff --git a/LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift b/LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift new file mode 100644 index 000000000..e7d4added --- /dev/null +++ b/LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift @@ -0,0 +1,83 @@ +// LoopFollow +// GeneralAlarmsStepView.swift + +import SwiftUI + +/// A short set of the alarm-wide settings that matter most up front: when the +/// day and night periods begin (used by day/night alarm options) and how alarm +/// sound is handled. The full set lives in the Menu. +struct GeneralAlarmsStepView: View { + @ObservedObject private var cfgStore = Storage.shared.alarmConfiguration + + private var dayBinding: Binding { + timeBinding(\.dayStart) + } + + private var nightBinding: Binding { + timeBinding(\.nightStart) + } + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "slider.horizontal.3", + title: "Alarm basics", + subtitle: "Set when your day and night begin, and how alarm sound behaves. You can fine-tune everything later in the Menu." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + DatePicker("Day starts", selection: dayBinding, displayedComponents: [.hourAndMinute]) + .datePickerStyle(.compact) + DatePicker("Night starts", selection: nightBinding, displayedComponents: [.hourAndMinute]) + .datePickerStyle(.compact) + } header: { + Text("Day / Night") + } footer: { + Text("Alarms can behave differently during the day and at night.") + } + + Section { + Toggle("Override system volume", isOn: $cfgStore.value.overrideSystemOutputVolume) + + if cfgStore.value.overrideSystemOutputVolume { + HStack { + Image(systemName: "speaker.fill") + .foregroundColor(.secondary) + Slider(value: $cfgStore.value.forcedOutputVolume, in: 0 ... 1) + Image(systemName: "speaker.wave.3.fill") + .foregroundColor(.secondary) + } + } + + Toggle("Play sound during calls", isOn: $cfgStore.value.audioDuringCalls) + } header: { + Text("Sound") + } footer: { + Text("Overriding the system volume lets alarms be heard even when your phone is silenced or in a Focus mode.") + } + } + } + + private func timeBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { + var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) + components.hour = cfgStore.value[keyPath: keyPath].hour + components.minute = cfgStore.value[keyPath: keyPath].minute + return Calendar.current.date(from: components) ?? Date() + }, + set: { newDate in + let hm = Calendar.current.dateComponents([.hour, .minute], from: newDate) + cfgStore.value[keyPath: keyPath] = TimeOfDay(hour: hm.hour ?? 0, minute: hm.minute ?? 0) + } + ) + } +} diff --git a/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift index 9276c744f..89b786c94 100644 --- a/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift +++ b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift @@ -3,61 +3,174 @@ import SwiftUI +/// The Nightscout connect phase, split into two internal pages: +/// 1. **Address** — enter the site URL; we validate it. A public site (or a URL +/// that already carries a token) is done here. A reachable site that needs a +/// token advances to page 2. An unreachable/invalid address stays put with the +/// error shown in the status pill so the user can correct it. +/// 2. **Token** — paste a token or create a read-only one from the API secret. struct NightscoutConnectStepView: View { @ObservedObject var viewModel: NightscoutSettingsViewModel + @ObservedObject var onboarding: OnboardingViewModel - private enum TokenMode: Hashable { - case haveToken - case createFromSecret - } + private enum Page { case address, token } + private enum TokenMode: Hashable { case haveToken, createFromSecret } + @State private var page: Page = .address @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) + VStack(spacing: 0) { + ConnectionStatusPill(kind: viewModel.statusKind, message: viewModel.friendlyStatus) + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 4) + .animation(.easeInOut(duration: 0.25), value: viewModel.statusKind) + + switch page { + case .address: + addressForm + case .token: + tokenForm } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) + footer + } + } + + // MARK: - Pages + + private var addressForm: some View { + Form { + titleSection( + "Connect to Nightscout", + "Enter your site address. We'll check it, and only ask for a token if your site needs one." + ) urlSection - tokenModeSection + } + } - switch mode { - case .haveToken: - tokenSection - case .createFromSecret: - secretSection + private var tokenForm: some View { + Form { + if viewModel.isConnected || viewModel.provisionedTokenPending { + tokenDoneSection + } else { + titleSection( + "Add a token", + "This site needs a token. Paste one, or have LoopFollow create a read-only token from your API secret." + ) + tokenModeSection + + switch mode { + case .haveToken: + tokenSection + case .createFromSecret: + secretSection + } } + } + } - statusSection + /// Shown once a token is in place, so the page reads as "done — continue" + /// rather than still asking the user to do something. + private var tokenDoneSection: some View { + Section { + EmptyView() + } header: { + VStack(alignment: .leading, spacing: 10) { + Text(viewModel.provisionedTokenPending ? "Token created" : "You're connected") + .font(.title2.weight(.bold)) + .foregroundColor(.primary) + Text(viewModel.provisionedTokenPending + ? "Your read-only token is ready. Tap Continue to keep going — your site may take a few minutes to start accepting it." + : "Your read-only token is set up. Tap Continue to keep going.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + @ViewBuilder + private var footer: some View { + switch page { + case .address: + OnboardingNavFooter( + continueEnabled: viewModel.isConnected || viewModel.addressNeedsToken, + showBack: onboarding.canGoBack, + onBack: { onboarding.goBack() }, + onContinue: addressContinue + ) + case .token: + OnboardingNavFooter( + continueEnabled: viewModel.isConnected || viewModel.provisionedTokenPending, + showBack: true, + onBack: { withAnimation(.easeInOut(duration: 0.25)) { page = .address } }, + onContinue: { onboarding.advance() } + ) + } + } + + private func addressContinue() { + if viewModel.isConnected { + onboarding.advance() + } else if viewModel.addressNeedsToken { + withAnimation(.easeInOut(duration: 0.25)) { page = .token } } } // MARK: - Sections + private func titleSection(_ title: String, _ subtitle: String) -> some View { + Section { + EmptyView() + } header: { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.title2.weight(.bold)) + .foregroundColor(.primary) + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + 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) + Section { + // `verbatim:` keeps SwiftUI from auto-linking the example URL in accent + // blue (which reads as an "active" field). + ZStack(alignment: .leading) { + if viewModel.nightscoutURL.isEmpty { + Text(verbatim: "https://your-site.example.com") + .foregroundColor(.secondary) } + TextField("", text: $viewModel.nightscoutURL) + .textContentType(.URL) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .foregroundColor(.primary) + .onChange(of: viewModel.nightscoutURL) { newValue in + viewModel.processURL(newValue) + } + } + } header: { + Text("Site URL") + } footer: { + Text("Enter or paste your full Nightscout address. If the link already includes a token, we'll fill both in for you.") } } @@ -72,7 +185,7 @@ struct NightscoutConnectStepView: View { 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.") + Text("Type or paste a token, or a full Nightscout URL that includes a token.") } } } @@ -127,19 +240,6 @@ struct NightscoutConnectStepView: View { } } - 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() { @@ -153,7 +253,7 @@ struct NightscoutConnectStepView: View { let token = try await NightscoutUtils.provisionReadOnlyToken(url: url, secret: secret) await MainActor.run { apiSecret = "" - viewModel.nightscoutToken = token + viewModel.confirmProvisionedToken(token) isProvisioning = false } } catch { @@ -183,3 +283,61 @@ struct NightscoutConnectStepView: View { } } } + +/// A pinned, color-coded status banner that morphs as the connection state +/// changes — replacing the static globe icon and "Status" row. +private struct ConnectionStatusPill: View { + let kind: NightscoutSettingsViewModel.ConnectionStatusKind + let message: String + + var body: some View { + HStack(spacing: 10) { + icon + .frame(width: 20) + Text(message) + .font(.subheadline.weight(.medium)) + .foregroundColor(color) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(color.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(color.opacity(0.3), lineWidth: 1) + ) + } + + private var color: Color { + switch kind { + case .idle: return .secondary + case .checking: return .orange + case .pending: return .blue + case .needsToken, .connected: return .green + case .error: return .red + } + } + + @ViewBuilder + private var icon: some View { + switch kind { + case .idle: + Image(systemName: "globe").foregroundColor(color) + case .checking: + ProgressView().scaleEffect(0.85) + case .pending: + Image(systemName: "clock.badge.checkmark").foregroundColor(color) + case .needsToken: + Image(systemName: "checkmark.circle").foregroundColor(color) + case .connected: + Image(systemName: "checkmark.circle.fill").foregroundColor(color) + case .error: + Image(systemName: "exclamationmark.triangle.fill").foregroundColor(color) + } + } +} diff --git a/LoopFollow/Onboarding/Steps/NotificationsStepView.swift b/LoopFollow/Onboarding/Steps/NotificationsStepView.swift new file mode 100644 index 000000000..4b75c4d88 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/NotificationsStepView.swift @@ -0,0 +1,67 @@ +// LoopFollow +// NotificationsStepView.swift + +import SwiftUI + +/// A short context screen ahead of the system notification prompt, following +/// Apple's pre-alert guidance. The system prompt is triggered from here — before +/// the final screen — so it never appears on top of "You're all set". +struct NotificationsStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @State private var requesting = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(alignment: .leading, spacing: 20) { + Image(systemName: "bell.badge.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + + Text("Stay informed") + .font(.title2.weight(.bold)) + + Text("LoopFollow uses notifications to deliver your alarms — like low or high glucose, or a missed reading. Without them, alarms can't reach you when the app is in the background.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + + Text("We'll ask iOS for permission next. You can change this any time in the Settings app.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 28) + + Spacer() + + VStack(spacing: 12) { + Button { + guard !requesting else { return } + requesting = true + NotificationAuthorization.requestIfNeeded { + viewModel.advance() + } + } label: { + Text("Enable Notifications") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .disabled(requesting) + + Button { viewModel.advance() } label: { + Text("Not now") + .font(.body.weight(.medium)) + } + .disabled(requesting) + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/LoopFollow/Onboarding/Steps/OverviewStepView.swift b/LoopFollow/Onboarding/Steps/OverviewStepView.swift new file mode 100644 index 000000000..3e57551ce --- /dev/null +++ b/LoopFollow/Onboarding/Steps/OverviewStepView.swift @@ -0,0 +1,72 @@ +// LoopFollow +// OverviewStepView.swift + +import SwiftUI + +/// A quick map of what the rest of setup covers, so the user knows what to +/// expect and roughly how long it takes. +struct OverviewStepView: View { + private struct Item: Identifiable { + let id = UUID() + let icon: String + let title: String + let detail: String + } + + private let items: [Item] = [ + Item(icon: "antenna.radiowaves.left.and.right", + title: "Connect your data", + detail: "Link a Nightscout site or a Dexcom Share account."), + Item(icon: "ruler", + title: "Units & metrics", + detail: "Choose how glucose and statistics are shown."), + Item(icon: "bell.badge.fill", + title: "Recommended alarms", + detail: "Turn on a few useful alarms with sensible defaults."), + Item(icon: "square.grid.2x2", + title: "Finish up", + detail: "Arrange your tabs and set notification preferences."), + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + OnboardingStepHeader( + systemImage: "list.bullet.rectangle", + title: "Here's what we'll do", + subtitle: "A quick guided setup. It only takes a few minutes, and you can change anything later in Settings." + ) + + VStack(spacing: 14) { + ForEach(items) { item in + HStack(alignment: .top, spacing: 14) { + Image(systemName: item.icon) + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + Text(item.detail) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + } + } + .padding(.horizontal) + } + .padding(.bottom, 24) + } + } +} diff --git a/LoopFollow/Onboarding/Steps/TabOrderStepView.swift b/LoopFollow/Onboarding/Steps/TabOrderStepView.swift new file mode 100644 index 000000000..89c15042e --- /dev/null +++ b/LoopFollow/Onboarding/Steps/TabOrderStepView.swift @@ -0,0 +1,22 @@ +// LoopFollow +// TabOrderStepView.swift + +import SwiftUI + +/// Lets the user arrange which features live in the tab bar versus the Menu, +/// during onboarding. Reuses the same drag-to-reorder list as the Settings +/// "Tabs" screen so behavior stays identical. +struct TabOrderStepView: View { + var body: some View { + VStack(spacing: 0) { + OnboardingStepHeader( + systemImage: "square.grid.2x2", + title: "Arrange your tabs", + subtitle: "Pick which features sit in the tab bar. The Menu can always open everything." + ) + .padding(.top, 8) + + TabCustomizationModal() + } + } +} diff --git a/LoopFollow/Onboarding/Steps/TelemetryStepView.swift b/LoopFollow/Onboarding/Steps/TelemetryStepView.swift new file mode 100644 index 000000000..e669d3cbe --- /dev/null +++ b/LoopFollow/Onboarding/Steps/TelemetryStepView.swift @@ -0,0 +1,80 @@ +// LoopFollow +// TelemetryStepView.swift + +import SwiftUI + +/// In-flow telemetry consent. Mirrors `TelemetryConsentView` but records the +/// decision and continues the wizard instead of dismissing a sheet. +struct TelemetryStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @State private var showPreview = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(alignment: .leading, spacing: 16) { + Image(systemName: "chart.bar.doc.horizontal") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + + Text("Help us help you") + .font(.title2.weight(.bold)) + + Text("You can choose to share anonymous information with the developers to help improve LoopFollow — such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + + Button { showPreview = true } label: { + Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass") + .font(.subheadline) + } + + Text("You can change this any time in Settings → General → Diagnostics.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 28) + + Spacer() + + VStack(spacing: 12) { + Button { decide(true) } label: { + Text("Yes, send anonymous stats") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + + Button { decide(false) } label: { + Text("No thanks") + .font(.body.weight(.medium)) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheet(isPresented: $showPreview) { + NavigationStack { + TelemetryPreviewView() + } + } + } + + private func decide(_ enabled: Bool) { + Storage.shared.telemetryEnabled.value = enabled + Storage.shared.telemetryConsentDecisionMade.value = true + if enabled { + // Fire the inaugural ping immediately, then start the 24h cadence. + Task.detached { + await TelemetryClient.shared.maybeSend() + TelemetryClient.shared.scheduleRecurring() + } + } + viewModel.advance() + } +} diff --git a/LoopFollow/Onboarding/Steps/WelcomeStepView.swift b/LoopFollow/Onboarding/Steps/WelcomeStepView.swift index da9cfad51..45110dafd 100644 --- a/LoopFollow/Onboarding/Steps/WelcomeStepView.swift +++ b/LoopFollow/Onboarding/Steps/WelcomeStepView.swift @@ -23,7 +23,7 @@ struct WelcomeStepView: View { 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.") + : "Let's get you connected to your data and set up a few recommended alarms. It only takes a few minutes.") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 7a6794b42..2f43360fd 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -16,6 +16,7 @@ struct DexcomSettingsView: View { HStack { Text("Username") TextField("Enter Username", text: $viewModel.userName) + .textContentType(.username) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) @@ -26,7 +27,8 @@ struct DexcomSettingsView: View { TogglableSecureInput( placeholder: "Enter Password", text: $viewModel.password, - style: .singleLine + style: .singleLine, + textContentType: .password ) } diff --git a/LoopFollow/Settings/DexcomSettingsViewModel.swift b/LoopFollow/Settings/DexcomSettingsViewModel.swift index 8c7fd5324..5a929cbe6 100644 --- a/LoopFollow/Settings/DexcomSettingsViewModel.swift +++ b/LoopFollow/Settings/DexcomSettingsViewModel.swift @@ -3,8 +3,16 @@ import Combine import Foundation +import ShareClient class DexcomSettingsViewModel: ObservableObject { + enum ConnectionStatusKind { + case idle + case checking + case connected + case error + } + /// Whether this is a fresh setup (credentials were empty when view appeared) private(set) var isFreshSetup: Bool = false @@ -12,6 +20,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != userName { Storage.shared.shareUserName.value = newValue + scheduleVerification() } } } @@ -20,6 +29,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != password { Storage.shared.sharePassword.value = newValue + scheduleVerification() } } } @@ -28,6 +38,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != server { Storage.shared.shareServer.value = newValue + scheduleVerification() } } } @@ -37,7 +48,94 @@ class DexcomSettingsViewModel: ObservableObject { !userName.isEmpty && !password.isEmpty } + // MARK: - Verification + + @Published var statusKind: ConnectionStatusKind = .idle + @Published var statusMessage: String = "Enter your username and password" + + /// True when a real Dexcom Share login succeeded. + @Published private(set) var isVerified: Bool = false + + /// The credentials were explicitly rejected by Dexcom (as opposed to a network + /// failure we can't draw a conclusion from). + private(set) var loginRejected: Bool = false + + /// Can move on: verified, or the only problem is that we couldn't reach Dexcom + /// (so we don't trap a user on a flaky network). A rejected login always blocks. + var canVerifyProceed: Bool { + hasCredentials && statusKind != .checking && !loginRejected + } + + private var verifyGeneration = 0 + private var cancellables = Set() + private let verifySubject = PassthroughSubject() + init() { isFreshSetup = Storage.shared.shareUserName.value.isEmpty + + verifySubject + .debounce(for: .seconds(1.5), scheduler: DispatchQueue.main) + .sink { [weak self] in self?.verify() } + .store(in: &cancellables) + + scheduleVerification() + } + + /// Resets status to "checking" and queues a debounced verification. + private func scheduleVerification() { + verifyGeneration += 1 + loginRejected = false + isVerified = false + if hasCredentials { + statusKind = .checking + statusMessage = "Checking your account…" + verifySubject.send() + } else { + statusKind = .idle + statusMessage = "Enter your username and password" + } + } + + private func verify() { + guard hasCredentials else { return } + + let generation = verifyGeneration + let serverURL = server == "US" + ? KnownShareServers.US.rawValue + : KnownShareServers.NON_US.rawValue + let client = ShareClient(username: userName, password: password, shareServer: serverURL) + + client.fetchData(1) { [weak self] error, _ in + DispatchQueue.main.async { + guard let self, generation == self.verifyGeneration else { return } + + if let error = error { + switch error { + case .loginError: + self.statusKind = .error + self.statusMessage = "Username or password not accepted" + self.isVerified = false + self.loginRejected = true + case .httpError: + self.statusKind = .error + self.statusMessage = "Network error — check your connection" + self.isVerified = false + self.loginRejected = false + default: + // Login succeeded but there's no recent reading yet; the + // credentials are valid, which is all we're confirming. + self.statusKind = .connected + self.statusMessage = "Connected" + self.isVerified = true + self.loginRejected = false + } + } else { + self.statusKind = .connected + self.statusMessage = "Connected" + self.isVerified = true + self.loginRejected = false + } + } + } } } diff --git a/LoopFollow/Settings/UnitsConfigurationView.swift b/LoopFollow/Settings/UnitsConfigurationView.swift index 427c4e5bd..72a80bf4d 100644 --- a/LoopFollow/Settings/UnitsConfigurationView.swift +++ b/LoopFollow/Settings/UnitsConfigurationView.swift @@ -11,6 +11,16 @@ struct UnitsConfigurationView: View { @State private var lowValue = Storage.shared.lowLine.value @State private var highValue = Storage.shared.highLine.value + /// Formats a mg/dL threshold pair in the currently selected glucose unit, + /// e.g. "70–180 mg/dL" or "3.9–10.0 mmol/L". + private func rangeBounds(_ lowMgdl: Double, _ highMgdl: Double) -> String { + let factor = glucoseUnit == .mmolL ? GlucoseConversion.mgDlToMmolL : 1.0 + let digits = glucoseUnit.fractionDigits + let low = Localizer.formatToLocalizedString(lowMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) + let high = Localizer.formatToLocalizedString(highMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) + return "\(low)–\(high) \(glucoseUnit.rawValue)" + } + var body: some View { Group { Section("Glucose") { @@ -24,7 +34,7 @@ struct UnitsConfigurationView: View { } } - Section("Range") { + Section { Picker("Range Mode", selection: $rangeMode) { Text("TIR").tag(TimeInRangeDisplayMode.tir) Text("TITR").tag(TimeInRangeDisplayMode.titr) @@ -58,9 +68,13 @@ struct UnitsConfigurationView: View { Observable.shared.chartSettingsChanged.value = true } } + } header: { + Text("Range") + } footer: { + Text("TIR — Time in Range, the share of readings within \(rangeBounds(70, 180)). TITR — Time in Tight Range, within \(rangeBounds(70, 140)). Custom — set your own low and high.") } - Section("Glycemic Metrics") { + Section { Picker("Metric", selection: Binding( get: { UnitSettingsStore.shared.glycemicMetricMode }, set: { UnitSettingsStore.shared.glycemicMetricMode = $0 } @@ -78,9 +92,13 @@ struct UnitsConfigurationView: View { Text("mmol/mol").tag(GlycemicOutputUnit.mmolMol) } .pickerStyle(.segmented) + } header: { + Text("Glycemic Metrics") + } footer: { + Text("eHbA1c — an A1c estimate from your average glucose. GMI — Glucose Management Indicator, another A1c estimate from average glucose. % and mmol/mol (IFCC) are two scales for the result.") } - Section("Variability") { + Section { Picker("Metric", selection: Binding( get: { UnitSettingsStore.shared.variabilityMetricMode }, set: { UnitSettingsStore.shared.variabilityMetricMode = $0 } @@ -89,6 +107,10 @@ struct UnitsConfigurationView: View { Text("CV").tag(VariabilityMetricMode.cv) } .pickerStyle(.segmented) + } header: { + Text("Variability") + } footer: { + Text("Std Dev — Standard Deviation, how much glucose swings around the average. CV — Coefficient of Variation, that swing relative to the average (Std Dev ÷ mean).") } } }