diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 0baf4be40..6837ca211 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,11 +7,12 @@ objects = { /* Begin PBXBuildFile section */ - B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; - B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; - B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; - B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; + 048429FF512415FC9045CE14 /* OnboardingNavFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1982C63D6BFEC44041F36C /* OnboardingNavFooter.swift */; }; + 0E7F523C7C777DFDDFFCC2A8 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.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 */; }; @@ -35,7 +36,11 @@ 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 */; }; + 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 */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; @@ -69,7 +74,27 @@ 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; 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 */; }; @@ -93,10 +118,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 */; }; @@ -105,13 +127,7 @@ DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; }; - B500000000000000000000C2 /* QuickPickSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000C1 /* QuickPickSectionHeader.swift */; }; DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; }; - CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; }; - DD7A3B5D2F1E8D9A00B4C6E1 /* BGDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */; }; - 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 */; }; @@ -121,7 +137,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 */; }; @@ -148,6 +163,10 @@ 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 */; }; + 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 */; }; @@ -163,6 +182,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 */; }; @@ -261,7 +283,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 */; }; @@ -291,7 +312,13 @@ 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 */; }; + 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 */; }; @@ -299,12 +326,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 */; }; @@ -435,15 +459,11 @@ 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 */; }; 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 */ @@ -478,11 +498,11 @@ /* 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 = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; @@ -503,6 +523,9 @@ 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 = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -535,9 +558,34 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -561,10 +609,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 = ""; }; @@ -573,13 +618,7 @@ DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = ""; }; - B500000000000000000000C1 /* QuickPickSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickSectionHeader.swift; sourceTree = ""; }; DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; - CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = ""; }; - DD7A3B5C2F1E8D9A00B4C6E1 /* BGDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGDisplayView.swift; sourceTree = ""; }; - 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 = ""; }; @@ -589,7 +628,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 = ""; }; @@ -609,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 = ""; }; @@ -618,6 +654,9 @@ 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 = ""; }; + 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 = ""; }; @@ -633,6 +672,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 = ""; }; @@ -733,7 +775,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 = ""; }; @@ -763,16 +804,17 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -902,7 +944,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 = ""; }; @@ -910,7 +951,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 = ""; }; @@ -921,10 +961,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 */ @@ -1034,6 +1114,60 @@ 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 = ( + 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */, + 17959684C89D6056922D9175 /* DataSourceChoiceStepView.swift */, + 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */, + 4D4B395B305B3EF00F20C798 /* DexcomConnectStepView.swift */, + 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; + sourceTree = ""; + }; + D58FD15DB5B78FC38B3864F1 /* Onboarding */ = { + isa = PBXGroup; + children = ( + BC3E3248623BA3A1C097F03A /* Steps */, + 69FA895A638B8FB7272E5BA8 /* OnboardingStep.swift */, + DB6C214697E4CF30F11EF561 /* OnboardingViewModel.swift */, + E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */, + 23FDA7658658AD4AE5A3D14B /* OnboardingStepHeader.swift */, + 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */, + BF1982C63D6BFEC44041F36C /* OnboardingNavFooter.swift */, + ); + name = Onboarding; + path = Onboarding; + sourceTree = ""; + }; DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */ = { isa = PBXGroup; children = ( @@ -1075,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 = ( @@ -1678,6 +1794,7 @@ DDC7E5CD2DC6637800EB1127 /* Storage */, DDEF503D2D32753A00999A5D /* Task */, FCC68871248A736700A0279D /* ViewControllers */, + D58FD15DB5B78FC38B3864F1 /* Onboarding */, ); path = LoopFollow; sourceTree = ""; @@ -1765,6 +1882,7 @@ DDDC01DC2E244B3100D9975C /* JWTManager.swift */, A1A1A10002000000A0CFEED2 /* LogRedactor.swift */, 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */, + 0A213D72341EF2C558030E88 /* NotificationAuthorization.swift */, ); path = Helpers; sourceTree = ""; @@ -1802,8 +1920,6 @@ 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, ); name = LoopFollowLAExtensionExtension; - packageProductDependencies = ( - ); productName = LoopFollowLAExtensionExtension; productReference = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -1825,8 +1941,6 @@ DDCC3AD72DDE1790006F1C10 /* Tests */, ); name = Tests; - packageProductDependencies = ( - ); productName = Tests; productReference = DDCC3AD62DDE1790006F1C10 /* Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -2463,6 +2577,26 @@ 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 */, + 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; }; @@ -2873,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; @@ -2905,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/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 6c9d8e884..85f6aa223 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..69241c4b7 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,21 +48,58 @@ 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 { + runPostOnboardingPrompts() } } - .sheet(isPresented: $showTelemetryConsent) { + .fullScreenCover(isPresented: $showOnboarding, onDismiss: { + // 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, 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) } } + /// 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 // 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() + } + } + @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..096b225ea 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,137 @@ 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) + + // 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 ) + /// 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..e8ed69afd --- /dev/null +++ b/LoopFollow/Helpers/NotificationAuthorization.swift @@ -0,0 +1,30 @@ +// 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. `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 { + 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/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..e15cf5cd7 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,138 @@ +// 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.showsProgressHeader { + header + } + + stepContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if viewModel.step.usesSharedFooter { + footer + } + } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + // MARK: - Chrome + + private var header: some View { + 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) + .padding(.bottom, 4) + } + + @ViewBuilder + private var stepContent: some View { + let content = Group { + 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, onboarding: viewModel) + case .copyFromPhone: + ConnectImportStepView(viewModel: viewModel) + default: + 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) + } + } + + 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 { + OnboardingNavFooter( + continueEnabled: viewModel.canProceed, + showBack: viewModel.canGoBack, + onBack: { withStepAnimation { viewModel.goBack() } }, + onContinue: { withStepAnimation { viewModel.advance() } } + ) + } + + 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/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 new file mode 100644 index 000000000..39a859d8d --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStep.swift @@ -0,0 +1,69 @@ +// LoopFollow +// OnboardingStep.swift + +import Foundation + +/// The phases of the first-run onboarding wizard. +/// +/// 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 + + /// 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 + } + } + + /// 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 + } + } + + /// Short name shown in the progress header for this phase. + var phaseTitle: String { + switch self { + 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 new file mode 100644 index 000000000..8b773dbf2 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStepHeader.swift @@ -0,0 +1,34 @@ +// 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 { + // 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) + .padding(.bottom, 2) + + Text(title) + .font(.title2.weight(.bold)) + .multilineTextAlignment(.leading) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 8) + } +} diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift new file mode 100644 index 000000000..198f68e95 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -0,0 +1,343 @@ +// LoopFollow +// OnboardingViewModel.swift + +import Combine +import SwiftUI +import UserNotifications + +/// 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 +/// 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 + case copyFromPhone + } + + /// 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 + 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() + + /// Called to dismiss the onboarding cover. + 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 + 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. + nightscoutViewModel.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + 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 + + /// 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, .overview, .units, .generalAlarms, .alarms, + .tabOrder, .notifications, .telemetry, .completion: + return true + case .dataSource: + return dataSource != nil + case .connect: + switch dataSource { + case .nightscout: return nightscoutViewModel.isConnected || nightscoutViewModel.provisionedTokenPending + case .dexcom: return dexcomViewModel.canVerifyProceed + case .copyFromPhone: return didImportSettings + case .none: return false + } + } + } + + /// 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 + } + + /// 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 { + 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() { + phaseProgress = nil + guard let index = activeSteps.firstIndex(of: step), + index + 1 < activeSteps.count + else { + finish() + return + } + step = activeSteps[index + 1] + } + + func goBack() { + 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 + /// 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 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 new file mode 100644 index 000000000..4b0804f8c --- /dev/null +++ b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift @@ -0,0 +1,208 @@ +// LoopFollow +// AlarmsStepView.swift + +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 { + 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: { + 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) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + Toggle("Enable this alarm", isOn: seed.isEnabled) + + if seed.wrappedValue.isEnabled { + controls(for: seed) + } + } + } + } + + // MARK: - Per-alarm controls + + @ViewBuilder + private func controls(for seed: Binding) -> some View { + switch seed.wrappedValue.type { + case .low: + 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(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: + doubleStepper(seed, label: "No reading for", range: 11 ... 121, step: 5, unit: "min", keyPath: \.threshold, default: 16) + case .notLooping: + doubleStepper(seed, label: "No loop for", range: 16 ... 61, step: 5, unit: "min", keyPath: \.threshold, default: 31) + case .battery: + 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 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: keyPath, default: def) + return Stepper(value: value, in: range, step: step) { + 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, + default def: Double + ) -> Binding { + Binding( + get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? def }, + set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 } + ) + } + + 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 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 new file mode 100644 index 000000000..8f953c160 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/CompletionStepView.swift @@ -0,0 +1,53 @@ +// 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("You're ready to go. You can adjust everything later from the Menu.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + Button { viewModel.finish() } label: { + Text("Finish") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + 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 new file mode 100644 index 000000000..b94abea15 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift @@ -0,0 +1,83 @@ +// 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 a glucose data source. Pick one now — you can change or add more 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: "sensor.tag.radiowaves.forward.fill", + title: "Dexcom Share", + 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) + } + .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..f9203d614 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift @@ -0,0 +1,89 @@ +// LoopFollow +// DexcomConnectStepView.swift + +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: "sensor.tag.radiowaves.forward.fill", + title: "Connect Dexcom Share", + subtitle: "Sign in with the Dexcom Share account that shares glucose data." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section(header: Text("Dexcom Share")) { + HStack { + Text("Username") + TextField("Enter Username", text: $viewModel.userName) + .textContentType(.username) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } + + HStack { + Text("Password") + TogglableSecureInput( + placeholder: "Enter Password", + text: $viewModel.password, + style: .singleLine, + textContentType: .password + ) + } + + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("Outside US").tag("NON-US") + } + .pickerStyle(.segmented) + } + + Section { + 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 new file mode 100644 index 000000000..89b786c94 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift @@ -0,0 +1,343 @@ +// LoopFollow +// NightscoutConnectStepView.swift + +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 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 { + 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 + } + + 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 + } + } + + 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 + } + } + } + } + + /// 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 { + // `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.") + } + } + + 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("Type or paste a token, or a full Nightscout URL that includes a token.") + } + } + } + + 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) + } + } + } + + // 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.confirmProvisionedToken(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." + } + } +} + +/// 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/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift new file mode 100644 index 000000000..06e7ab52d --- /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 values and 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..45110dafd --- /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 recommended alarms. It only takes a few minutes.") + .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/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 99a4dc32b..2f43360fd 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -14,8 +14,9 @@ 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) + .textContentType(.username) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) @@ -26,13 +27,14 @@ struct DexcomSettingsView: View { TogglableSecureInput( placeholder: "Enter Password", text: $viewModel.password, - style: .singleLine + style: .singleLine, + textContentType: .password ) } Picker("Server", selection: $viewModel.server) { Text("US").tag("US") - Text("NON-US").tag("NON-US") + Text("Outside US").tag("NON-US") } .pickerStyle(SegmentedPickerStyle()) } 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).") } } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4dd7370a8..87b1f3d37 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -202,6 +202,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))