diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 0baf4be40..116876e6d 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,11 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 0E7F523C7C777DFDDFFCC2A8 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */; }; B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; + 2EADEE2EE5B46EF64ADF7348 /* NightscoutConnectStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; @@ -35,7 +37,10 @@ 37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; }; 37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 4B286B98268852C8ACF98E8E /* AlarmsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */; }; + 510B3641E5D3A7FBBAA713DD /* DataSourceChoiceStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17959684C89D6056922D9175 /* DataSourceChoiceStepView.swift */; }; 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; + 63AE109876D073B929203D51 /* OnboardingContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; @@ -69,7 +74,20 @@ 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; + 6953970EE39241506F90FF5B /* UnitsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A6FDE7D2E8EA86132A08B5 /* UnitsStepView.swift */; }; + 6D32AE9BDBC241941EAD3D53 /* OnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C214697E4CF30F11EF561 /* OnboardingViewModel.swift */; }; + 8B5DC3C7CB15EFE705F89952 /* CompletionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB18D2D8AC7FCABCA3287745 /* CompletionStepView.swift */; }; + 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; + A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; + A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F3 /* StatsDisplayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F5 /* StatsDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */; }; + AA1B2C3D4E5F6A7B8C9D0E2F /* LoopFollowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */; }; + AB1CD0012C7B30D40048F05C /* RemoteDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */; }; ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; + BB2C3D4E5F6A7B8C9D0E2F2A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */; }; + CC3D4E5F6A7B8C9D0E2F2A3B /* MoreMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */; }; + CCE18AC5C70DD24C4F07EEEF /* OnboardingStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69FA895A638B8FB7272E5BA8 /* OnboardingStep.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; @@ -93,10 +111,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 */; }; @@ -107,11 +122,6 @@ 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 +131,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 +157,7 @@ DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */; }; DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */; }; DD4AFB6B2DB6BF2A00BB593F /* Binding+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */; }; + DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */; }; DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */; }; DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */; }; DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; }; @@ -163,6 +173,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 +274,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 +303,12 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; + EE5F6A7B8C9D0E2F2A3B4C5D /* NightscoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */; }; F19449721F3B792730A0F4FD /* PendingFutureCarb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */; }; + F373C36F29C2B30181012301 /* DexcomConnectStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4B395B305B3EF00F20C798 /* DexcomConnectStepView.swift */; }; + F38017DF9689DF1F7639653A /* NotificationAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A213D72341EF2C558030E88 /* NotificationAuthorization.swift */; }; + F957B82F8D80D5D762CDE068 /* LoopFollowLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */; }; + F9E4698DE8AE0D5FEB518AF1 /* OnboardingStepHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FDA7658658AD4AE5A3D14B /* OnboardingStepHeader.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* SpeakBG.swift */; }; @@ -299,12 +316,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,7 +449,6 @@ FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886A24898FD800A0279D /* ObservationToken.swift */; }; FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886C2489909D00A0279D /* AnyConvertible.swift */; }; FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886E2489A53800A0279D /* AppConstants.swift */; }; - 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD2A27C24C9D044009F7B7B /* Globals.swift */; }; FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */; }; FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; @@ -483,6 +496,9 @@ 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 = ""; }; + 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 +519,8 @@ 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessStore.swift; sourceTree = ""; }; 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessMarker.swift; sourceTree = ""; }; + 4D4B395B305B3EF00F20C798 /* DexcomConnectStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DexcomConnectStepView.swift; sourceTree = ""; }; + 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -535,9 +553,25 @@ 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; + 69FA895A638B8FB7272E5BA8 /* OnboardingStep.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingStep.swift; sourceTree = ""; }; + 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoopFollowLogo.swift; sourceTree = ""; }; + 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConnectStepView.swift; sourceTree = ""; }; + 96A6FDE7D2E8EA86132A08B5 /* UnitsStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UnitsStepView.swift; sourceTree = ""; }; + A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; + A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayModel.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayView.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; + AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopFollowApp.swift; sourceTree = ""; }; + AB1CD0022C7B30D40048F05C /* RemoteDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDiagnostics.swift; sourceTree = ""; }; + B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AlarmsStepView.swift; sourceTree = ""; }; B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; + BB18D2D8AC7FCABCA3287745 /* CompletionStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CompletionStepView.swift; sourceTree = ""; }; + BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; + CC3D4E5F6A7B8C9D0E1F2A3B /* MoreMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuView.swift; sourceTree = ""; }; + DB6C214697E4CF30F11EF561 /* OnboardingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -561,10 +595,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 = ""; }; @@ -575,11 +606,6 @@ 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 +615,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 = ""; }; @@ -618,6 +643,7 @@ DD4AFB602DB68BBC00BB593F /* AlarmListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmListView.swift; sourceTree = ""; }; DD4AFB662DB68C5500BB593F /* UUID+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UUID+Identifiable.swift"; sourceTree = ""; }; DD4AFB6A2DB6BF2A00BB593F /* Binding+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Optional.swift"; sourceTree = ""; }; + DD4E5F6A7B8C9D0E1F2A3B4C /* RemoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContentView.swift; sourceTree = ""; }; DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageUpdater.swift; sourceTree = ""; }; DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinCartridgeChange.swift; sourceTree = ""; }; DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = ""; }; @@ -633,6 +659,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 +762,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 +791,16 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OnboardingContainerView.swift; sourceTree = ""; }; E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; + EE5F6A7B8C9D0E1F2A3B4C5D /* NightscoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutContentView.swift; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; FC16A97E249969E2003D6245 /* Graphs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Graphs.swift; sourceTree = ""; }; FC16A98024996C07003D6245 /* DateTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTime.swift; sourceTree = ""; }; FC1BDD2A24A22650001B652C /* Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stats.swift; sourceTree = ""; }; FC1BDD2C24A23204001B652C /* MainViewController+updateStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+updateStats.swift"; sourceTree = ""; }; - A1B2C3D4E5F6A7B8C9D0E1F2 /* StatsDisplayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayModel.swift; sourceTree = ""; }; - A1B2C3D4E5F6A7B8C9D0E1F4 /* StatsDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDisplayView.swift; sourceTree = ""; }; FC1BDD2E24A232A3001B652C /* DataStructs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStructs.swift; sourceTree = ""; }; FC3AE7B4249E8E0E00AAE1E0 /* LoopFollow.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LoopFollow.xcdatamodel; sourceTree = ""; }; FC5A5C3C2497B229009C550E /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; @@ -902,7 +930,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 +937,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 +947,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 +1100,35 @@ path = Pods; sourceTree = ""; }; + BC3E3248623BA3A1C097F03A /* Steps */ = { + isa = PBXGroup; + children = ( + 5E5AA9246BBC5EA725658F54 /* WelcomeStepView.swift */, + 17959684C89D6056922D9175 /* DataSourceChoiceStepView.swift */, + 89F3C47FBF847CD6A38EF0B7 /* NightscoutConnectStepView.swift */, + 4D4B395B305B3EF00F20C798 /* DexcomConnectStepView.swift */, + 96A6FDE7D2E8EA86132A08B5 /* UnitsStepView.swift */, + B1C5EFE1ECBDFD9812B38E5A /* AlarmsStepView.swift */, + BB18D2D8AC7FCABCA3287745 /* CompletionStepView.swift */, + ); + name = Steps; + path = Steps; + sourceTree = ""; + }; + D58FD15DB5B78FC38B3864F1 /* Onboarding */ = { + isa = PBXGroup; + children = ( + BC3E3248623BA3A1C097F03A /* Steps */, + 69FA895A638B8FB7272E5BA8 /* OnboardingStep.swift */, + DB6C214697E4CF30F11EF561 /* OnboardingViewModel.swift */, + E62DB628D3B182C207B92ABC /* OnboardingContainerView.swift */, + 23FDA7658658AD4AE5A3D14B /* OnboardingStepHeader.swift */, + 88F3D35D067D089482506CBF /* LoopFollowLogo.swift */, + ); + name = Onboarding; + path = Onboarding; + sourceTree = ""; + }; DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */ = { isa = PBXGroup; children = ( @@ -1678,6 +1773,7 @@ DDC7E5CD2DC6637800EB1127 /* Storage */, DDEF503D2D32753A00999A5D /* Task */, FCC68871248A736700A0279D /* ViewControllers */, + D58FD15DB5B78FC38B3864F1 /* Onboarding */, ); path = LoopFollow; sourceTree = ""; @@ -1765,6 +1861,7 @@ DDDC01DC2E244B3100D9975C /* JWTManager.swift */, A1A1A10002000000A0CFEED2 /* LogRedactor.swift */, 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */, + 0A213D72341EF2C558030E88 /* NotificationAuthorization.swift */, ); path = Helpers; sourceTree = ""; @@ -1802,8 +1899,6 @@ 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, ); name = LoopFollowLAExtensionExtension; - packageProductDependencies = ( - ); productName = LoopFollowLAExtensionExtension; productReference = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -1825,8 +1920,6 @@ DDCC3AD72DDE1790006F1C10 /* Tests */, ); name = Tests; - packageProductDependencies = ( - ); productName = Tests; productReference = DDCC3AD62DDE1790006F1C10 /* Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -2463,6 +2556,19 @@ DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */, DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */, DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */, + CCE18AC5C70DD24C4F07EEEF /* OnboardingStep.swift in Sources */, + 6D32AE9BDBC241941EAD3D53 /* OnboardingViewModel.swift in Sources */, + 63AE109876D073B929203D51 /* OnboardingContainerView.swift in Sources */, + F9E4698DE8AE0D5FEB518AF1 /* OnboardingStepHeader.swift in Sources */, + 0E7F523C7C777DFDDFFCC2A8 /* WelcomeStepView.swift in Sources */, + 510B3641E5D3A7FBBAA713DD /* DataSourceChoiceStepView.swift in Sources */, + 2EADEE2EE5B46EF64ADF7348 /* NightscoutConnectStepView.swift in Sources */, + F373C36F29C2B30181012301 /* DexcomConnectStepView.swift in Sources */, + 6953970EE39241506F90FF5B /* UnitsStepView.swift in Sources */, + 4B286B98268852C8ACF98E8E /* AlarmsStepView.swift in Sources */, + 8B5DC3C7CB15EFE705F89952 /* CompletionStepView.swift in Sources */, + F957B82F8D80D5D762CDE068 /* LoopFollowLogo.swift in Sources */, + F38017DF9689DF1F7639653A /* NotificationAuthorization.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LoopFollow/Alarm/AlarmListView.swift b/LoopFollow/Alarm/AlarmListView.swift index 3240208db..9aad1b95d 100644 --- a/LoopFollow/Alarm/AlarmListView.swift +++ b/LoopFollow/Alarm/AlarmListView.swift @@ -154,6 +154,9 @@ struct AlarmListView: View { AddAlarmSheet { type in let new = Alarm(type: type) store.value.append(new) + // First alarm the user adds is the moment notifications become + // useful — request authorization here rather than at app launch. + NotificationAuthorization.requestIfNeeded() sheetInfo = .editor(id: new.id, isNew: true) } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 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..15fb71c6d 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -15,6 +15,7 @@ struct MainTabView: View { @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition @State private var showTelemetryConsent = false + @State private var showOnboarding = false private var orderedItems: [TabItem] { Storage.shared.orderedTabBarItems() @@ -47,14 +48,21 @@ struct MainTabView: View { // onAppear (not app launch) keeps it off the BG-only refresh path. MainViewController.bootstrap() - // One-time consent prompt. Previously presented by SceneDelegate, - // which was removed in the storyboard→SwiftUI migration; without - // this, fresh installs stay permanently undecided and telemetry - // never sends. The storage flag keeps it to a single appearance. - if !Storage.shared.telemetryConsentDecisionMade.value { - showTelemetryConsent = true + // Show the first-run onboarding once for everyone. Returning users + // get a prominent Skip on the welcome screen. The telemetry consent + // prompt is deferred until onboarding is dismissed so the two never + // appear on top of one another. + if !Storage.shared.hasCompletedOnboarding.value { + showOnboarding = true + } else { + presentTelemetryConsentIfNeeded() } } + .fullScreenCover(isPresented: $showOnboarding, onDismiss: { + presentTelemetryConsentIfNeeded() + }) { + OnboardingContainerView(onClose: { showOnboarding = false }) + } .sheet(isPresented: $showTelemetryConsent) { // User must explicitly choose — no swipe-to-dismiss. TelemetryConsentView() @@ -62,6 +70,16 @@ struct MainTabView: View { } } + // One-time telemetry consent prompt. Previously presented by SceneDelegate, + // which was removed in the storyboard→SwiftUI migration; without this, fresh + // installs stay permanently undecided and telemetry never sends. The storage + // flag keeps it to a single appearance. + private func presentTelemetryConsentIfNeeded() { + if !Storage.shared.telemetryConsentDecisionMade.value { + showTelemetryConsent = true + } + } + @ViewBuilder private func tabContent(for item: TabItem) -> some View { switch item { diff --git a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift index c4a39874c..31184db0e 100644 --- a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift +++ b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift @@ -8,6 +8,12 @@ import Foundation class BLEManager: NSObject, ObservableObject { static let shared = BLEManager() + /// Whether the shared instance has been created (and therefore a + /// CBCentralManager exists / the Bluetooth prompt has been triggered). + /// Reading this does not instantiate `shared`, so callers can avoid forcing + /// Bluetooth initialization — and its permission prompt — when not needed. + private(set) static var isInitialized = false + @Published private(set) var devices: [BLEDevice] = [] private var centralManager: CBCentralManager! @@ -15,6 +21,7 @@ class BLEManager: NSObject, ObservableObject { override private init() { super.init() + BLEManager.isInitialized = true centralManager = CBCentralManager( delegate: self, diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift index 21cbdf43f..1bf23fe0c 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift @@ -33,6 +33,12 @@ class BackgroundRefreshSettingsViewModel: ObservableObject { private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) { LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)") - BLEManager.shared.disconnect() + // Touch BLEManager only when switching to a Bluetooth mode (the user is + // opting in, so the permission prompt belongs here) or when it's already + // running and needs to be torn down. Switching between non-BLE modes must + // not initialize Bluetooth — that would prompt without cause. + if newValue.isBluetooth || BLEManager.isInitialized { + BLEManager.shared.disconnect() + } } } diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index 0ba3664b1..8b844c983 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -76,7 +76,10 @@ class BackgroundAlertManager { removeDeliveredNotifications() let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth - let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval() + // Only query BLEManager for a Bluetooth mode — touching it otherwise would + // initialize CoreBluetooth and trigger the permission prompt for users + // (e.g. Silent Tune) who never opted into Bluetooth. + let expectedHeartbeat = isBluetoothActive ? BLEManager.shared.expectedHeartbeatInterval() : nil // Define alerts let alerts: [BackgroundAlert] = [ diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 04c5ff14b..cf698fbbe 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -1,6 +1,7 @@ // LoopFollow // NightscoutUtils.swift +import CryptoKit import Foundation class NightscoutUtils { @@ -385,6 +386,125 @@ class NightscoutUtils { return responseString } + // MARK: - Token Provisioning + + /// Name of the Nightscout authorization subject LoopFollow creates when a + /// user provisions a token from their API secret. + static let provisionedSubjectName = "LoopFollow" + + private struct AuthSubject: Decodable { + let id: String? + let name: String? + let accessToken: String? + let roles: [String]? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case name, accessToken, roles + } + } + + /// Creates (or reuses) a read-only Nightscout access token using the site's + /// API secret. The secret only authorizes these requests and is never + /// persisted. Returns the access token for a `readable` subject named + /// `provisionedSubjectName`. + /// + /// The full API secret authenticates as Nightscout's `admin` role (the `*` + /// permission), which includes `admin:api:subjects:create`. + /// + /// Nightscout serves the subjects list from an in-memory cache that doesn't + /// refresh promptly after a write, so a freshly-created subject (and its + /// token) can't be read back reliably right after creating it. Instead we + /// derive the token locally: it's a pure function of the subject's `_id` + /// (returned by the create call) and the API secret. See `accessToken(for:)`. + static func provisionReadOnlyToken(url: String, secret: String) async throws -> String { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { throw NightscoutError.emptyAddress } + guard let baseURL = URL(string: trimmedURL), + trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://") + else { throw NightscoutError.invalidURL } + + let secretHash = sha1Hex(secret) + + // Reuse an existing subject if one is already visible (idempotent re-runs + // once the site's cache has caught up). + if let existing = try await fetchProvisionedToken(baseURL: baseURL, secretHash: secretHash) { + return existing + } + + let id = try await createReadOnlySubject(baseURL: baseURL, secretHash: secretHash) + return accessToken(forName: provisionedSubjectName, id: id, secretHash: secretHash) + } + + private static func fetchProvisionedToken(baseURL: URL, secretHash: String) async throws -> String? { + let url = baseURL.appendingPathComponent("api/v2/authorization/subjects") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(secretHash, forHTTPHeaderField: "api-secret") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.cachePolicy = .reloadIgnoringLocalCacheData + + let (data, response) = try await URLSession.shared.data(for: request) + try validateProvisioningResponse(response) + + let subjects = try JSONDecoder().decode([AuthSubject].self, from: data) + return subjects.first(where: { $0.name == provisionedSubjectName })?.accessToken + } + + /// Creates the subject and returns its `_id`. + private static func createReadOnlySubject(baseURL: URL, secretHash: String) async throws -> String { + let url = baseURL.appendingPathComponent("api/v2/authorization/subjects") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(secretHash, forHTTPHeaderField: "api-secret") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "name": provisionedSubjectName, + "roles": ["readable"], + ]) + + let (data, response) = try await URLSession.shared.data(for: request) + try validateProvisioningResponse(response) + + let subject = try JSONDecoder().decode(AuthSubject.self, from: data) + guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown } + return id + } + + /// Reproduces Nightscout's subject-token derivation (`lib/authorization`): + /// abbrev = name lowercased, non-`\w` characters removed, first 10 chars + /// digest = sha1( sha1Hex(apiSecret) + subjectId ) + /// token = "\(abbrev)-\(digest[0..<16])" + private static func accessToken(forName name: String, id: String, secretHash: String) -> String { + let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_") + let abbrev = String(name.lowercased().filter { allowed.contains($0) }.prefix(10)) + let digest = sha1Hex(secretHash + id) + return abbrev + "-" + String(digest.prefix(16)) + } + + private static func validateProvisioningResponse(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw NightscoutError.networkError + } + switch http.statusCode { + case 200 ..< 300: + return + case 401, 403: + // The API secret was missing or wrong. + throw NightscoutError.invalidToken + case 404: + throw NightscoutError.siteNotFound + default: + throw NightscoutError.unknown + } + } + + private static func sha1Hex(_ string: String) -> String { + Insecure.SHA1.hash(data: Data(string.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + static func extractErrorReason(from responseString: String) -> String { // 1) Try to parse the entire string as JSON and return the "message" if let data = responseString.data(using: .utf8) { diff --git a/LoopFollow/Helpers/NotificationAuthorization.swift b/LoopFollow/Helpers/NotificationAuthorization.swift new file mode 100644 index 000000000..f93d948c2 --- /dev/null +++ b/LoopFollow/Helpers/NotificationAuthorization.swift @@ -0,0 +1,23 @@ +// LoopFollow +// NotificationAuthorization.swift + +import UserNotifications + +/// Requests notification authorization lazily, the first time the user opts into +/// a feature that needs it (alarms). This keeps the system prompt off the very +/// first launch so it doesn't front the onboarding flow. +enum NotificationAuthorization { + /// Asks for authorization only when the user hasn't decided yet. Safe to call + /// repeatedly — it's a no-op once the status is determined. + static func requestIfNeeded() { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + guard settings.authorizationStatus == .notDetermined else { return } + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + if !granted { + LogManager.shared.log(category: .general, message: "User has declined notifications") + } + } + } + } +} diff --git a/LoopFollow/Onboarding/LoopFollowLogo.swift b/LoopFollow/Onboarding/LoopFollowLogo.swift new file mode 100644 index 000000000..f3f23e39b --- /dev/null +++ b/LoopFollow/Onboarding/LoopFollowLogo.swift @@ -0,0 +1,108 @@ +// LoopFollow +// LoopFollowLogo.swift + +import SwiftUI + +/// The LoopFollow mark, rebuilt in SwiftUI as the full app-icon face — a glassy +/// rounded square with the blue "loop" ring — so it has real visual mass when +/// tilted in 3D (a bare ring collapses to a line edge-on). +struct LoopFollowLogo: View { + var size: CGFloat = 120 + + // Colors sampled from the app icon (loopfollow-icon.svg). + private let lightBlue = Color(red: 0.357, green: 0.639, blue: 0.961) // #5BA3F5 + private let midBlue = Color(red: 0.290, green: 0.565, blue: 0.886) // #4A90E2 + private let darkBlue = Color(red: 0.227, green: 0.482, blue: 0.784) // #3A7BC8 + + var body: some View { + let corner = size * 0.225 + let ringInset = size * 0.20 + let ringWidth = size * 0.17 + + ZStack { + // Glassy white card (the icon face). + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [Color(white: 0.99), Color.white, Color(white: 0.93)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + // Soft sheen across the top half. + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.75), Color.clear], + startPoint: .top, + endPoint: .center + ) + ) + + // Blue glass ring. + Circle() + .stroke( + LinearGradient( + colors: [lightBlue, midBlue, darkBlue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: ringWidth + ) + .padding(ringInset) + + // Inner shadow on the ring for depth. + Circle() + .stroke(Color.black.opacity(0.16), lineWidth: ringWidth * 0.2) + .blur(radius: ringWidth * 0.12) + .padding(ringInset) + .mask(Circle().stroke(lineWidth: ringWidth).padding(ringInset)) + + // Specular highlight on the upper-left of the ring. + Circle() + .trim(from: 0.55, to: 0.80) + .stroke( + LinearGradient( + colors: [Color.white.opacity(0.9), Color.white.opacity(0.0)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + style: StrokeStyle(lineWidth: ringWidth * 0.42, lineCap: .round) + ) + .blur(radius: ringWidth * 0.08) + .padding(ringInset) + } + .frame(width: size, height: size) + } +} + +/// LoopFollow logo that lands like a coin: it starts edge-on (rotated 90° about +/// the vertical axis) and springs open to face the viewer, overshooting a little +/// past flat and rocking back to rest. Respects Reduce Motion by rendering flat. +struct AnimatedLoopFollowLogo: View { + var size: CGFloat = 140 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var angle: Double = 90 + + var body: some View { + LoopFollowLogo(size: size) + .rotation3DEffect( + .degrees(angle), + axis: (x: 0, y: 1, z: 0), + perspective: 0.7 + ) + // Grounding shadow so the landing reads as dimensional. + .shadow(color: .black.opacity(0.28), radius: size * 0.08, x: 0, y: size * 0.06) + .onAppear { + if reduceMotion { + angle = 0 + } else { + withAnimation(.spring(response: 0.85, dampingFraction: 0.5).delay(0.15)) { + angle = 0 + } + } + } + } +} diff --git a/LoopFollow/Onboarding/OnboardingContainerView.swift b/LoopFollow/Onboarding/OnboardingContainerView.swift new file mode 100644 index 000000000..a53168c58 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,136 @@ +// LoopFollow +// OnboardingContainerView.swift + +import SwiftUI + +/// Root of the first-run onboarding wizard. Owns the shared chrome — progress +/// bar and Back/Next footer — and swaps in the view for the current step. +struct OnboardingContainerView: View { + @StateObject private var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(onClose: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: OnboardingViewModel(onClose: onClose)) + } + + var body: some View { + VStack(spacing: 0) { + if viewModel.step.showsChrome { + header + } + + stepContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if viewModel.step.showsChrome { + footer + } + } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + // MARK: - Chrome + + private var header: some View { + HStack(spacing: 12) { + OnboardingProgressBar(progress: viewModel.progress) + Button("Skip") { viewModel.skip() } + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + } + + @ViewBuilder + private var stepContent: some View { + let content = Group { + switch viewModel.step { + case .welcome: + WelcomeStepView(viewModel: viewModel) + case .dataSource: + DataSourceChoiceStepView(viewModel: viewModel) + case .connect: + switch viewModel.dataSource { + case .dexcom: + DexcomConnectStepView(viewModel: viewModel.dexcomViewModel) + default: + NightscoutConnectStepView(viewModel: viewModel.nightscoutViewModel) + } + case .units: + UnitsStepView() + case .alarms: + AlarmsStepView(viewModel: viewModel) + case .completion: + CompletionStepView(viewModel: viewModel) + } + } + + if reduceMotion { + content.id(viewModel.step) + } else { + content + .id(viewModel.step) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + } + + private var footer: some View { + HStack { + if viewModel.step.previous != nil { + Button { + withStepAnimation { viewModel.goBack() } + } label: { + Label("Back", systemImage: "chevron.left") + .font(.body.weight(.medium)) + } + .buttonStyle(.bordered) + } + + Spacer() + + Button { + withStepAnimation { viewModel.advance() } + } label: { + Text("Continue") + .font(.body.weight(.semibold)) + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canProceed) + } + .padding() + .background(.bar) + } + + private func withStepAnimation(_ change: () -> Void) { + if reduceMotion { + change() + } else { + withAnimation(.easeInOut(duration: 0.3)) { change() } + } + } +} + +/// Thin segmented progress indicator shown at the top of each chrome'd step. +private struct OnboardingProgressBar: View { + let progress: Double + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color(.systemGray5)) + Capsule() + .fill(Color.accentColor) + .frame(width: max(0, min(1, progress)) * geo.size.width) + } + } + .frame(height: 6) + } +} diff --git a/LoopFollow/Onboarding/OnboardingStep.swift b/LoopFollow/Onboarding/OnboardingStep.swift new file mode 100644 index 000000000..90a20f6b9 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStep.swift @@ -0,0 +1,37 @@ +// LoopFollow +// OnboardingStep.swift + +import Foundation + +/// The ordered steps of the first-run onboarding wizard. +/// +/// `connect` renders either the Nightscout or Dexcom screen depending on the +/// data source the user picks in `dataSource`, so the ordering stays linear and +/// the progress indicator has a stable length. +enum OnboardingStep: Int, CaseIterable { + case welcome + case dataSource + case connect + case units + case alarms + case completion + + var next: OnboardingStep? { + OnboardingStep(rawValue: rawValue + 1) + } + + var previous: OnboardingStep? { + OnboardingStep(rawValue: rawValue - 1) + } + + /// Steps that show the progress bar and the Back/Next footer. The welcome and + /// completion screens are full-bleed and provide their own call-to-action. + var showsChrome: Bool { + switch self { + case .welcome, .completion: + return false + case .dataSource, .connect, .units, .alarms: + return true + } + } +} diff --git a/LoopFollow/Onboarding/OnboardingStepHeader.swift b/LoopFollow/Onboarding/OnboardingStepHeader.swift new file mode 100644 index 000000000..cabc416a5 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStepHeader.swift @@ -0,0 +1,32 @@ +// LoopFollow +// OnboardingStepHeader.swift + +import SwiftUI + +/// Consistent icon + title + subtitle header used at the top of each step body. +struct OnboardingStepHeader: View { + let systemImage: String + let title: String + let subtitle: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .padding(.bottom, 2) + + Text(title) + .font(.title2.weight(.bold)) + .multilineTextAlignment(.center) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.top, 8) + } +} diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift new file mode 100644 index 000000000..4ac25c5ed --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -0,0 +1,142 @@ +// LoopFollow +// OnboardingViewModel.swift + +import Combine +import SwiftUI + +/// Drives the onboarding wizard: tracks the current step, the chosen data +/// source, the seeded alarms, and persists everything when the user finishes. +/// +/// The child connection view models are the same ones used by the regular +/// settings screens, so URL/token/credential entry and validation behave +/// identically here and there. +@MainActor +final class OnboardingViewModel: ObservableObject { + enum DataSource: Hashable { + case nightscout + case dexcom + } + + /// A single default alarm offered on the alarms step. + struct SeedAlarm: Identifiable { + let id = UUID() + var alarm: Alarm + var isEnabled: Bool = true + + var type: AlarmType { alarm.type } + } + + @Published var step: OnboardingStep = .welcome + @Published var dataSource: DataSource? + @Published var seedAlarms: [SeedAlarm] + + let nightscoutViewModel = NightscoutSettingsViewModel() + let dexcomViewModel = DexcomSettingsViewModel() + + /// Called to dismiss the onboarding cover. + private let onClose: () -> Void + private var cancellables = Set() + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + seedAlarms = [.low, .high, .missedReading, .notLooping, .battery] + .map { SeedAlarm(alarm: Alarm(type: $0)) } + + // Re-publish child changes so the footer's `canProceed` stays in sync + // with live connection validation. + nightscoutViewModel.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + dexcomViewModel.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + } + + // MARK: - Derived state + + /// True when the user already has a working data source — used to make + /// skipping prominent for returning users. + var isAlreadyConfigured: Bool { + let nightscout = !Storage.shared.url.value.isEmpty + let dexcom = !Storage.shared.shareUserName.value.isEmpty + && !Storage.shared.sharePassword.value.isEmpty + return nightscout || dexcom + } + + var canProceed: Bool { + switch step { + case .welcome, .units, .alarms, .completion: + return true + case .dataSource: + return dataSource != nil + case .connect: + switch dataSource { + case .nightscout: return nightscoutViewModel.isConnected + case .dexcom: return dexcomViewModel.hasCredentials + case .none: return false + } + } + } + + /// Whether a seeded alarm should be offered. Device/system alarms (Not + /// Looping, Low Battery, …) rely on loop and uploader data that only a + /// Nightscout site provides, so they're hidden for a Dexcom-only follower who + /// has no such data. Other groups are always offered. + func isSeedAlarmOffered(_ type: AlarmType) -> Bool { + guard type.group == .device else { return true } + return dataSource == .nightscout || !Storage.shared.url.value.isEmpty + } + + /// Progress fraction (0...1) across the chrome'd steps, for the progress bar. + var progress: Double { + let total = Double(OnboardingStep.allCases.count - 1) + guard total > 0 else { return 0 } + return Double(step.rawValue) / total + } + + // MARK: - Navigation + + func advance() { + guard let next = step.next else { + finish() + return + } + step = next + } + + func goBack() { + guard let previous = step.previous else { return } + step = previous + } + + /// Skip the rest of setup. Marks onboarding complete without seeding alarms + /// or touching units, leaving any existing configuration untouched. + func skip() { + Storage.shared.hasCompletedOnboarding.value = true + onClose() + } + + /// Finish setup: seed selected alarms, mark units/onboarding complete, and + /// kick a refresh so the home screen loads data immediately. + func finish() { + persistSeededAlarms() + Storage.shared.hasConfiguredUnits.value = true + Storage.shared.hasCompletedOnboarding.value = true + onClose() + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + // MARK: - Alarm seeding + + private func persistSeededAlarms() { + var alarms = Storage.shared.alarms.value + let existingTypes = Set(alarms.map(\.type)) + + for seed in seedAlarms where seed.isEnabled && isSeedAlarmOffered(seed.type) { + guard !existingTypes.contains(seed.type) else { continue } + alarms.append(seed.alarm) + } + + Storage.shared.alarms.value = alarms + } +} diff --git a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift new file mode 100644 index 000000000..b987dc547 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift @@ -0,0 +1,134 @@ +// LoopFollow +// AlarmsStepView.swift + +import SwiftUI + +struct AlarmsStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "bell.badge.fill", + title: "Useful alarms", + subtitle: "We'll set up a few commonly used alarms with sensible defaults. Turn off any you don't want and adjust the rest." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + ForEach($viewModel.seedAlarms) { $seed in + if viewModel.isSeedAlarmOffered(seed.type) { + Toggle(isOn: $seed.isEnabled) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(meta(for: seed.type).title) + Text(meta(for: seed.type).detail) + .font(.caption) + .foregroundColor(.secondary) + } + } icon: { + Image(systemName: meta(for: seed.type).icon) + } + } + + if seed.isEnabled { + control(for: $seed) + } + } + } + } footer: { + Text("These come with sensible defaults — fine-tune them any time in the Alarms tab.") + } + } + } + + // MARK: - Per-alarm control + + @ViewBuilder + private func control(for seed: Binding) -> some View { + switch seed.wrappedValue.type { + case .low: + BGPicker( + title: "Alert below", + range: 40 ... 150, + value: doubleBinding(seed, keyPath: \.belowBG, default: 80) + ) + case .high: + BGPicker( + title: "Alert above", + range: 120 ... 350, + value: doubleBinding(seed, keyPath: \.aboveBG, default: 180) + ) + case .missedReading: + stepperRow(seed, label: "No reading for", range: 11 ... 121, step: 5, unit: "min", default: 16) + case .notLooping: + stepperRow(seed, label: "No loop for", range: 16 ... 61, step: 5, unit: "min", default: 31) + case .battery: + stepperRow(seed, label: "At or below", range: 0 ... 100, step: 5, unit: "%", default: 20) + default: + EmptyView() + } + } + + private func stepperRow( + _ seed: Binding, + label: String, + range: ClosedRange, + step: Double, + unit: String, + default def: Double + ) -> some View { + let value = doubleBinding(seed, keyPath: \.threshold, default: def) + return Stepper(value: value, in: range, step: step) { + HStack { + Text(label) + Spacer() + Text("\(Int(value.wrappedValue)) \(unit)") + .foregroundColor(.secondary) + } + } + } + + private func doubleBinding( + _ seed: Binding, + keyPath: WritableKeyPath, + default def: Double + ) -> Binding { + Binding( + get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? def }, + set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 } + ) + } + + // MARK: - Copy + + private struct AlarmMeta { + let title: String + let detail: String + let icon: String + } + + private func meta(for type: AlarmType) -> AlarmMeta { + switch type { + case .low: + return AlarmMeta(title: "Low glucose", detail: "Warns when glucose is low, now or soon.", icon: "arrow.down.circle.fill") + case .high: + return AlarmMeta(title: "High glucose", detail: "Warns when glucose stays high.", icon: "arrow.up.circle.fill") + case .missedReading: + return AlarmMeta(title: "Missed readings", detail: "Warns when glucose stops updating.", icon: "wifi.slash") + case .notLooping: + return AlarmMeta(title: "Not looping", detail: "Warns when the loop stops running.", icon: "arrow.triangle.2.circlepath") + case .battery: + return AlarmMeta(title: "Phone battery", detail: "Warns when your phone battery is low.", icon: "battery.25") + default: + return AlarmMeta(title: type.rawValue, detail: "", icon: "bell.fill") + } + } +} diff --git a/LoopFollow/Onboarding/Steps/CompletionStepView.swift b/LoopFollow/Onboarding/Steps/CompletionStepView.swift new file mode 100644 index 000000000..4c6a09ce5 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/CompletionStepView.swift @@ -0,0 +1,60 @@ +// LoopFollow +// CompletionStepView.swift + +import SwiftUI + +struct CompletionStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animate = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 20) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 96, weight: .semibold)) + .foregroundStyle(.green.gradient) + .scaleEffect(animate || reduceMotion ? 1 : 0.6) + .opacity(animate || reduceMotion ? 1 : 0) + + Text("You're all set") + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + + Text("You're ready to go. You can adjust everything later from the Menu and Alarms tabs.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + Button { viewModel.finish() } label: { + Text("Done") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + // The user just set up alarms, so this is the natural moment to ask + // for notification permission — on context, and after onboarding + // rather than fronting it on first launch. + if viewModel.seedAlarms.contains(where: { $0.isEnabled }) { + NotificationAuthorization.requestIfNeeded() + } + + guard !reduceMotion else { return } + withAnimation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.1)) { + animate = true + } + } + } +} diff --git a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift new file mode 100644 index 000000000..6192b742f --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift @@ -0,0 +1,77 @@ +// LoopFollow +// DataSourceChoiceStepView.swift + +import SwiftUI + +struct DataSourceChoiceStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + + var body: some View { + ScrollView { + VStack(spacing: 24) { + OnboardingStepHeader( + systemImage: "antenna.radiowaves.left.and.right", + title: "Choose a data source", + subtitle: "LoopFollow needs 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: "drop.fill", + title: "Dexcom Share", + detail: "Follow glucose directly from a Dexcom Share account. Simplest option when there's no Nightscout site." + ) + } + .padding(.horizontal) + } + .padding(.bottom, 24) + } + } + + private func choiceCard(source: OnboardingViewModel.DataSource, icon: String, title: String, detail: String) -> some View { + let isSelected = viewModel.dataSource == source + return Button { + viewModel.dataSource = source + } label: { + HStack(alignment: .top, spacing: 14) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundColor(.primary) + Text(detail) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isSelected ? Color.accentColor : Color(.systemGray3)) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} diff --git a/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift new file mode 100644 index 000000000..14680c01b --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift @@ -0,0 +1,60 @@ +// LoopFollow +// DexcomConnectStepView.swift + +import SwiftUI + +struct DexcomConnectStepView: View { + @ObservedObject var viewModel: DexcomSettingsViewModel + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "drop.fill", + title: "Connect Dexcom Share", + subtitle: "Sign in with the Dexcom Share account 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) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } + + HStack { + Text("Password") + TogglableSecureInput( + placeholder: "Enter Password", + text: $viewModel.password, + style: .singleLine + ) + } + + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("Outside US").tag("NON-US") + } + .pickerStyle(.segmented) + } + + Section { + HStack { + Image(systemName: viewModel.hasCredentials ? "checkmark.circle.fill" : "circle") + .foregroundColor(viewModel.hasCredentials ? .green : .secondary) + Text(viewModel.hasCredentials ? "Credentials entered" : "Enter your username and password") + .foregroundColor(.secondary) + } + } + } + } +} diff --git a/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift new file mode 100644 index 000000000..9276c744f --- /dev/null +++ b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift @@ -0,0 +1,185 @@ +// LoopFollow +// NightscoutConnectStepView.swift + +import SwiftUI + +struct NightscoutConnectStepView: View { + @ObservedObject var viewModel: NightscoutSettingsViewModel + + private enum TokenMode: Hashable { + case haveToken + case createFromSecret + } + + @State private var mode: TokenMode = .haveToken + @State private var apiSecret: String = "" + @State private var isProvisioning = false + @State private var provisioningError: String? + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "globe", + title: "Connect to Nightscout", + subtitle: "Enter your site address. If your site needs a token, LoopFollow can create a read-only one for you." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + urlSection + tokenModeSection + + switch mode { + case .haveToken: + tokenSection + case .createFromSecret: + secretSection + } + + statusSection + } + } + + // MARK: - Sections + + private var urlSection: some View { + Section(header: Text("URL")) { + TextField("https://your-site.example.com", text: $viewModel.nightscoutURL) + .textContentType(.URL) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: viewModel.nightscoutURL) { newValue in + viewModel.processURL(newValue) + } + } + } + + private var tokenModeSection: some View { + Section { + Picker("Token", selection: $mode) { + Text("I have a token").tag(TokenMode.haveToken) + Text("Create one for me").tag(TokenMode.createFromSecret) + } + .pickerStyle(.segmented) + } footer: { + if mode == .createFromSecret { + Text("Your API secret is used once to create a read-only access token and is never stored.") + } else { + Text("Paste a token, or a full Nightscout URL that includes a token. Leave empty if your site is public.") + } + } + } + + private var tokenSection: some View { + Section(header: Text("Access Token")) { + HStack { + Text("Token") + TogglableSecureInput( + placeholder: "Enter Token", + text: $viewModel.nightscoutToken, + style: .singleLine, + textContentType: .password + ) + } + } + } + + private var secretSection: some View { + Section(header: Text("API Secret")) { + HStack { + Text("Secret") + TogglableSecureInput( + placeholder: "Enter API Secret", + text: $apiSecret, + style: .singleLine, + textContentType: .password + ) + } + + Button(action: createToken) { + HStack { + Spacer() + if isProvisioning { + ProgressView() + } else { + Text("Create Read-Only Token") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(isProvisioning + || apiSecret.isEmpty + || viewModel.nightscoutURL.isEmpty) + + if let provisioningError { + Text(provisioningError) + .font(.footnote) + .foregroundColor(.red) + } + } + } + + private var statusSection: some View { + Section(header: Text("Status")) { + HStack { + Text(viewModel.nightscoutStatus) + if viewModel.isConnected { + Spacer() + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } + } + } + } + + // MARK: - Token provisioning + + private func createToken() { + provisioningError = nil + isProvisioning = true + let url = viewModel.nightscoutURL + let secret = apiSecret + + Task { + do { + let token = try await NightscoutUtils.provisionReadOnlyToken(url: url, secret: secret) + await MainActor.run { + apiSecret = "" + viewModel.nightscoutToken = token + isProvisioning = false + } + } catch { + await MainActor.run { + isProvisioning = false + provisioningError = message(for: error) + } + } + } + } + + private func message(for error: Error) -> String { + guard let nsError = error as? NightscoutUtils.NightscoutError else { + return "Could not create a token. Please try again." + } + switch nsError { + case .invalidToken: + return "That API secret was rejected. Check it and try again." + case .invalidURL, .emptyAddress: + return "Please enter a valid site URL first." + case .siteNotFound: + return "Couldn't reach that site. Check the URL." + case .networkError: + return "Network error. Check your connection and try again." + case .tokenRequired, .unknown: + return "Could not create a token. Please try again." + } + } +} diff --git a/LoopFollow/Onboarding/Steps/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift new file mode 100644 index 000000000..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..da9cfad51 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/WelcomeStepView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// WelcomeStepView.swift + +import SwiftUI + +struct WelcomeStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animate = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 20) { + AnimatedLoopFollowLogo(size: 140) + .frame(height: 160) + .opacity(animate || reduceMotion ? 1 : 0) + + Text("Welcome to LoopFollow") + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + + Text(viewModel.isAlreadyConfigured + ? "You're already set up. You can skip this guide, or walk through it to review your settings." + : "Let's get you connected to your data and set up a few useful alarms. It only takes a minute.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + VStack(spacing: 12) { + if viewModel.isAlreadyConfigured { + Button { viewModel.skip() } label: { + primaryLabel("Skip") + } + .buttonStyle(.borderedProminent) + + Button { advance() } label: { + Text("Review setup anyway") + .font(.body.weight(.medium)) + } + } else { + Button { advance() } label: { + primaryLabel("Get Started") + } + .buttonStyle(.borderedProminent) + + Button { viewModel.skip() } label: { + Text("Skip for now") + .font(.body.weight(.medium)) + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.1)) { + animate = true + } + } + } + + private func primaryLabel(_ text: String) -> some View { + Text(text) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + + private func advance() { + if reduceMotion { + viewModel.advance() + } else { + withAnimation(.easeInOut(duration: 0.3)) { viewModel.advance() } + } + } +} diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 99a4dc32b..7a6794b42 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -14,8 +14,8 @@ struct DexcomSettingsView: View { Form { Section(header: Text("Dexcom Settings")) { HStack { - Text("User Name") - TextField("Enter User Name", text: $viewModel.userName) + Text("Username") + TextField("Enter Username", text: $viewModel.userName) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) @@ -32,7 +32,7 @@ struct DexcomSettingsView: View { Picker("Server", selection: $viewModel.server) { Text("US").tag("US") - Text("NON-US").tag("NON-US") + Text("Outside US").tag("NON-US") } .pickerStyle(SegmentedPickerStyle()) } 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))