diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index be5c64958..f14bb3d14 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -403,8 +403,6 @@ 11CFD785273662DF0082D557 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CFD783273662DF0082D557 /* Server.swift */; }; 11D826F124E39F2E005B8A86 /* CoreNFC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 11D826F024E39F2D005B8A86 /* CoreNFC.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 11DA6B4B27137A60008ADFAF /* InputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DA6B4A27137A60008ADFAF /* InputAccessoryView.swift */; }; - 11DA6B4D2713900E008ADFAF /* OnboardingPermissionWorkflowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DA6B4C2713900E008ADFAF /* OnboardingPermissionWorkflowController.swift */; }; - 11DA6B4F2713912F008ADFAF /* OnboardingPermissionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DA6B4E2713912F008ADFAF /* OnboardingPermissionViewController.swift */; }; 11DC6BAB24E23780002D9FDA /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = B63CCDCF2164714900123C50 /* Intents.intentdefinition */; settings = {ATTRIBUTES = (no_codegen, ); }; }; 11DE822E24FAC51100E636B8 /* IncomingURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DE822D24FAC51000E636B8 /* IncomingURLHandler.swift */; }; 11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DE822F24FAE66F00E636B8 /* UIWindow+Additions.swift */; }; @@ -415,7 +413,6 @@ 11E1639B250B1B760076D612 /* OnboardingStateObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E16399250B1B760076D612 /* OnboardingStateObservation.swift */; }; 11E5CF8124BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E5CF8024BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift */; }; 11E5CF8224BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E5CF8024BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift */; }; - 11E99A5027156854003C8A65 /* OnboardingTerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E99A4F27156854003C8A65 /* OnboardingTerminalViewController.swift */; }; 11ED43962726599D00B5FD45 /* OnboardingAuthStepModels.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ED43952726599D00B5FD45 /* OnboardingAuthStepModels.test.swift */; }; 11ED439827265B9C00B5FD45 /* OnboardingAuthStepNotify.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ED439727265B9C00B5FD45 /* OnboardingAuthStepNotify.test.swift */; }; 11ED439A27265DE800B5FD45 /* OnboardingAuthStepRegister.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ED439927265DE800B5FD45 /* OnboardingAuthStepRegister.test.swift */; }; @@ -587,6 +584,10 @@ 4214388C2CF5F1D700E2D44D /* ServerFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */; }; 4214388D2CF5F1D700E2D44D /* ServerFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */; }; 421960702CA2AE1600F7134E /* WidgetAssistViewTintedWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4219606F2CA2AE1600F7134E /* WidgetAssistViewTintedWrapper.swift */; }; + 42196ACE2DA5A49600BD501E /* Bonjour.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B25BCC2130CAB400678C2C /* Bonjour.swift */; }; + 42196AD22DA5AF3D00BD501E /* MockBonjour.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42196AD02DA5AF3D00BD501E /* MockBonjour.swift */; }; + 42196ADA2DA5AF7A00BD501E /* OnboardingServersListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42196AD52DA5AF7A00BD501E /* OnboardingServersListViewModelTests.swift */; }; + 42196ADE2DA5B58200BD501E /* OnboardingNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42196ADD2DA5B58200BD501E /* OnboardingNavigationTests.swift */; }; 421B1C182BD6524E001ED18C /* WidgetBuilderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421B1C172BD6524E001ED18C /* WidgetBuilderViewModel.swift */; }; 421B1C1D2BD65C04001ED18C /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421B1C1B2BD65BFA001ED18C /* View+ConditionalModifier.swift */; }; 4221ED352D009EF700BAE3EB /* AppDatabaseUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4221ED332D009BD000BAE3EB /* AppDatabaseUpdater.swift */; }; @@ -631,6 +632,16 @@ 424151FD2CD8F27100D7A6F9 /* CarPlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABB0B82C888AA10081461D /* CarPlayConfig.swift */; }; 424627332C98D8E900EF7B43 /* WidgetBasicViewTintedWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424627322C98D8E900EF7B43 /* WidgetBasicViewTintedWrapper.swift */; }; 424627342C98D8E900EF7B43 /* WidgetBasicViewTintedWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424627322C98D8E900EF7B43 /* WidgetBasicViewTintedWrapper.swift */; }; + 42462E692D9ED75900ECC8A7 /* LocationPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E682D9ED75900ECC8A7 /* LocationPermissionView.swift */; }; + 42462E6D2DA4094300ECC8A7 /* WebhookSensorId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E6C2DA4094300ECC8A7 /* WebhookSensorId.swift */; }; + 42462E6E2DA4094300ECC8A7 /* WebhookSensorId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E6C2DA4094300ECC8A7 /* WebhookSensorId.swift */; }; + 42462E712DA4114800ECC8A7 /* Sizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E702DA4114800ECC8A7 /* Sizes.swift */; }; + 42462E722DA4114800ECC8A7 /* Sizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E702DA4114800ECC8A7 /* Sizes.swift */; }; + 42462E732DA4114800ECC8A7 /* Sizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E702DA4114800ECC8A7 /* Sizes.swift */; }; + 42462E752DA53F7200ECC8A7 /* LocationPermissionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E742DA53F7200ECC8A7 /* LocationPermissionViewModel.swift */; }; + 42462E772DA5421D00ECC8A7 /* CornerRadiusSizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E762DA5421D00ECC8A7 /* CornerRadiusSizes.swift */; }; + 42462E782DA5421D00ECC8A7 /* CornerRadiusSizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E762DA5421D00ECC8A7 /* CornerRadiusSizes.swift */; }; + 42462E792DA5421D00ECC8A7 /* CornerRadiusSizes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42462E762DA5421D00ECC8A7 /* CornerRadiusSizes.swift */; }; 424A7F462B188946008C8DF3 /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F452B188946008C8DF3 /* WidgetBackground.swift */; }; 424A7F482B188BF3008C8DF3 /* WidgetContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */; }; 424D2D102C89DACE00C610F1 /* HAAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424D2D0F2C89DACE00C610F1 /* HAAppEntity.swift */; }; @@ -719,10 +730,26 @@ 427940812B836A1A001D7E14 /* AppIntentVocabulary.plist in Resources */ = {isa = PBXBuildFile; fileRef = 42805A142B0226050095414C /* AppIntentVocabulary.plist */; }; 427E92BE2D65E43A0001566B /* WidgetInteractionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427E92BB2D65E3FE0001566B /* WidgetInteractionType.swift */; }; 427E92BF2D65E43A0001566B /* WidgetInteractionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427E92BB2D65E3FE0001566B /* WidgetInteractionType.swift */; }; + 427FEE032D9BE7690047C00C /* OnboardingServersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE022D9BE7690047C00C /* OnboardingServersListView.swift */; }; + 427FEE072D9C03DE0047C00C /* OnboardingScanningInstanceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE062D9C03DE0047C00C /* OnboardingScanningInstanceRow.swift */; }; + 427FEE092D9C04050047C00C /* OnboardingServersListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE082D9C04050047C00C /* OnboardingServersListViewModel.swift */; }; + 427FEE0B2D9C05EF0047C00C /* ServersScanAnimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE0A2D9C05EF0047C00C /* ServersScanAnimationView.swift */; }; + 427FEE0E2D9C22310047C00C /* ManualURLEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE0D2D9C22310047C00C /* ManualURLEntryView.swift */; }; + 427FEE562D9D39A50047C00C /* OnboardingNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE552D9D39A50047C00C /* OnboardingNavigationView.swift */; }; + 427FEE592D9D48120047C00C /* View+HA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE572D9D48120047C00C /* View+HA.swift */; }; + 427FEE632D9EA1400047C00C /* OnboardingNavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE622D9EA1400047C00C /* OnboardingNavigationViewModel.swift */; }; + 427FEE662D9EBC430047C00C /* OnboardingPermissionsNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE652D9EBC430047C00C /* OnboardingPermissionsNavigationView.swift */; }; + 427FEE682D9ECFD70047C00C /* PrivacyNoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427FEE672D9ECFD70047C00C /* PrivacyNoteView.swift */; }; 428338442BA1BB4F004798C2 /* Spaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428338422BA1BAFB004798C2 /* Spaces.swift */; }; 428338452BA1BB4F004798C2 /* Spaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428338422BA1BAFB004798C2 /* Spaces.swift */; }; 4285C5512D355F9900DADE45 /* WidgetCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4285C5502D355F9900DADE45 /* WidgetCreationView.swift */; }; 4285C5532D35658000DADE45 /* TileCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4285C5522D35658000DADE45 /* TileCard.swift */; }; + 428626052DA5CCAE00D58D13 /* CollapsibleViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428626022DA5CCAE00D58D13 /* CollapsibleViewTests.swift */; }; + 428626062DA5CCAE00D58D13 /* ExternalLinkButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428626032DA5CCAE00D58D13 /* ExternalLinkButtonTests.swift */; }; + 428626072DA5CCAE00D58D13 /* HAButtonStylesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428626042DA5CCAE00D58D13 /* HAButtonStylesTests.swift */; }; + 4286260D2DA5CD1B00D58D13 /* CornerRadiusSizesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428626082DA5CD1B00D58D13 /* CornerRadiusSizesTests.swift */; }; + 4286260E2DA5CD1B00D58D13 /* WebhookSensorIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4286260B2DA5CD1B00D58D13 /* WebhookSensorIdTests.swift */; }; + 4286260F2DA5CD1B00D58D13 /* SpacesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428626092DA5CD1B00D58D13 /* SpacesTests.swift */; }; 428830EB2C6E3A8D0012373D /* WatchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */; }; 428830ED2C6E3A9A0012373D /* WatchHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */; }; 4289DDAA2C85AB4C003591C2 /* AssistAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425FF0552C8216B3000AA641 /* AssistAppIntent.swift */; }; @@ -862,7 +889,7 @@ 42E6C08C2CE4F7A8007CA622 /* DownloadManagerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E6C08B2CE4F7A8007CA622 /* DownloadManagerViewModel.swift */; }; 42E95C552CA44FC90010ECE3 /* SafariWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C542CA44FC90010ECE3 /* SafariWebView.swift */; }; 42E95C572CA45EFA0010ECE3 /* OnboardingErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C562CA45EFA0010ECE3 /* OnboardingErrorView.swift */; }; - 42E95C592CA46AD50010ECE3 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C582CA46AD50010ECE3 /* ActivityView.swift */; }; + 42E95C592CA46AD50010ECE3 /* ShareActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C582CA46AD50010ECE3 /* ShareActivityView.swift */; }; 42E9AFFF2CE63944009DDA46 /* AudioOutputSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E9AFFE2CE63944009DDA46 /* AudioOutputSensor.swift */; }; 42E9B0002CE63944009DDA46 /* AudioOutputSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E9AFFE2CE63944009DDA46 /* AudioOutputSensor.swift */; }; 42EB030A2C6E4D0E00A184A6 /* WatchMagicViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EB03092C6E4D0E00A184A6 /* WatchMagicViewRow.swift */; }; @@ -938,7 +965,6 @@ 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; - B6022223226DBA3800E8DBFE /* OnboardingNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022222226DBA3800E8DBFE /* OnboardingNavigationViewController.swift */; }; B60248001FBD343000998205 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; B605C891226E9DAC00EF46DD /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B605C890226E9DAC00EF46DD /* Permissions.swift */; }; B60615BB1D1F117700249C11 /* MorganFreemanSounds.csv in Resources */ = {isa = PBXBuildFile; fileRef = B60614B51D1F117700249C11 /* MorganFreemanSounds.csv */; }; @@ -1073,7 +1099,6 @@ B60616BB1D1F117800249C11 /* US-EN-Daisy-Water-Heater-Leak.wav in Resources */ = {isa = PBXBuildFile; fileRef = B60615BA1D1F117700249C11 /* US-EN-Daisy-Water-Heater-Leak.wav */; }; B613936924F728F8002B8C5D /* InputOutputDeviceSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B613936824F728F8002B8C5D /* InputOutputDeviceSensor.swift */; }; B613936A24F728F8002B8C5D /* InputOutputDeviceSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B613936824F728F8002B8C5D /* InputOutputDeviceSensor.swift */; }; - B616B299227ED68E00828165 /* Bonjour.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B25BCC2130CAB400678C2C /* Bonjour.swift */; }; B6221F6522266F9F00502A30 /* WebhookRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6221F6122266C4000502A30 /* WebhookRequest.swift */; }; B6221F6622266FA000502A30 /* WebhookRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6221F6122266C4000502A30 /* WebhookRequest.swift */; }; B626AAF11D8F972800A0D225 /* SettingsDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B626AAF01D8F972800A0D225 /* SettingsDetailViewController.swift */; }; @@ -1104,9 +1129,7 @@ B661FB68226B961400E541DD /* WebSocketBridge.js in Resources */ = {isa = PBXBuildFile; fileRef = B661FB67226B961400E541DD /* WebSocketBridge.js */; }; B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B661FB69226BBDA900E541DD /* SettingsViewController.swift */; }; B661FB6F226BCCAD00E541DD /* ConnectionSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B661FB6E226BCCAD00E541DD /* ConnectionSettingsViewController.swift */; }; - B661FB7A226C197900E541DD /* OnboardingManualURLViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B661FB79226C197900E541DD /* OnboardingManualURLViewController.swift */; }; B661FC7E226C87BB00E541DD /* home.json in Resources */ = {isa = PBXBuildFile; fileRef = B661FC7D226C87BB00E541DD /* home.json */; }; - B661FC88226D478300E541DD /* OnboardingScanningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B661FC87226D478300E541DD /* OnboardingScanningViewController.swift */; }; B66C58A8215086F0004AB261 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66C58A7215086F0004AB261 /* IntentHandler.swift */; }; B66C58AC215086F0004AB261 /* HomeAssistant-Extensions-Intents.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B66C58A5215086F0004AB261 /* HomeAssistant-Extensions-Intents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B672333E225DB68B0031D629 /* WebSocketMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B672333D225DB68B0031D629 /* WebSocketMessage.swift */; }; @@ -1847,8 +1870,6 @@ 11CFD783273662DF0082D557 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; 11D826F024E39F2D005B8A86 /* CoreNFC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreNFC.framework; path = System/Library/Frameworks/CoreNFC.framework; sourceTree = SDKROOT; }; 11DA6B4A27137A60008ADFAF /* InputAccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputAccessoryView.swift; sourceTree = ""; }; - 11DA6B4C2713900E008ADFAF /* OnboardingPermissionWorkflowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPermissionWorkflowController.swift; sourceTree = ""; }; - 11DA6B4E2713912F008ADFAF /* OnboardingPermissionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPermissionViewController.swift; sourceTree = ""; }; 11DE822D24FAC51000E636B8 /* IncomingURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingURLHandler.swift; sourceTree = ""; }; 11DE822F24FAE66F00E636B8 /* UIWindow+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+Additions.swift"; sourceTree = ""; }; 11DE9D8325B6103C0081C0ED /* Home Assistant Launcher.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Home Assistant Launcher.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1858,7 +1879,6 @@ 11DE9F3925B614EB0081C0ED /* Application.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Application.xib; sourceTree = ""; }; 11E16399250B1B760076D612 /* OnboardingStateObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStateObservation.swift; sourceTree = ""; }; 11E5CF8024BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+BackgroundTask.swift"; sourceTree = ""; }; - 11E99A4F27156854003C8A65 /* OnboardingTerminalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTerminalViewController.swift; sourceTree = ""; }; 11ED43952726599D00B5FD45 /* OnboardingAuthStepModels.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthStepModels.test.swift; sourceTree = ""; }; 11ED439727265B9C00B5FD45 /* OnboardingAuthStepNotify.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthStepNotify.test.swift; sourceTree = ""; }; 11ED439927265DE800B5FD45 /* OnboardingAuthStepRegister.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthStepRegister.test.swift; sourceTree = ""; }; @@ -1980,6 +2000,9 @@ 421155222D354F3F00A71630 /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; 421155242D355C6700A71630 /* WidgetBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBuilderView.swift; sourceTree = ""; }; 4219606F2CA2AE1600F7134E /* WidgetAssistViewTintedWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetAssistViewTintedWrapper.swift; sourceTree = ""; }; + 42196AD02DA5AF3D00BD501E /* MockBonjour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBonjour.swift; sourceTree = ""; }; + 42196AD52DA5AF7A00BD501E /* OnboardingServersListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingServersListViewModelTests.swift; sourceTree = ""; }; + 42196ADD2DA5B58200BD501E /* OnboardingNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNavigationTests.swift; sourceTree = ""; }; 421B1C172BD6524E001ED18C /* WidgetBuilderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBuilderViewModel.swift; sourceTree = ""; }; 421B1C1B2BD65BFA001ED18C /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = ""; }; 4221ED332D009BD000BAE3EB /* AppDatabaseUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabaseUpdater.swift; sourceTree = ""; }; @@ -2038,6 +2061,11 @@ 4242A2D02B2B5C9F00E9F001 /* es-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "es-ES"; path = "es-ES.lproj/AppIntentVocabulary.plist"; sourceTree = ""; }; 4242A2D12B2B5C9F00E9F001 /* es-MX */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "es-MX"; path = "es-MX.lproj/AppIntentVocabulary.plist"; sourceTree = ""; }; 424627322C98D8E900EF7B43 /* WidgetBasicViewTintedWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBasicViewTintedWrapper.swift; sourceTree = ""; }; + 42462E682D9ED75900ECC8A7 /* LocationPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionView.swift; sourceTree = ""; }; + 42462E6C2DA4094300ECC8A7 /* WebhookSensorId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookSensorId.swift; sourceTree = ""; }; + 42462E702DA4114800ECC8A7 /* Sizes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sizes.swift; sourceTree = ""; }; + 42462E742DA53F7200ECC8A7 /* LocationPermissionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionViewModel.swift; sourceTree = ""; }; + 42462E762DA5421D00ECC8A7 /* CornerRadiusSizes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadiusSizes.swift; sourceTree = ""; }; 424A7F452B188946008C8DF3 /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetContentMargin.swift; sourceTree = ""; }; 424D2D0F2C89DACE00C610F1 /* HAAppEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAAppEntity.swift; sourceTree = ""; }; @@ -2126,11 +2154,27 @@ 42790C452C4808FA00E31B38 /* AppleLikeBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleLikeBottomSheet.swift; sourceTree = ""; }; 4279407E2B8369EA001D7E14 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = bg; path = bg.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; 427E92BB2D65E3FE0001566B /* WidgetInteractionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetInteractionType.swift; sourceTree = ""; }; + 427FEE022D9BE7690047C00C /* OnboardingServersListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingServersListView.swift; sourceTree = ""; }; + 427FEE062D9C03DE0047C00C /* OnboardingScanningInstanceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScanningInstanceRow.swift; sourceTree = ""; }; + 427FEE082D9C04050047C00C /* OnboardingServersListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingServersListViewModel.swift; sourceTree = ""; }; + 427FEE0A2D9C05EF0047C00C /* ServersScanAnimationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersScanAnimationView.swift; sourceTree = ""; }; + 427FEE0D2D9C22310047C00C /* ManualURLEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualURLEntryView.swift; sourceTree = ""; }; + 427FEE552D9D39A50047C00C /* OnboardingNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNavigationView.swift; sourceTree = ""; }; + 427FEE572D9D48120047C00C /* View+HA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+HA.swift"; sourceTree = ""; }; + 427FEE622D9EA1400047C00C /* OnboardingNavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNavigationViewModel.swift; sourceTree = ""; }; + 427FEE652D9EBC430047C00C /* OnboardingPermissionsNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPermissionsNavigationView.swift; sourceTree = ""; }; + 427FEE672D9ECFD70047C00C /* PrivacyNoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyNoteView.swift; sourceTree = ""; }; 42805A132B0226050095414C /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Base; path = Base.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; 4283383F2BA1B17C004798C2 /* AssistRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistRequests.swift; sourceTree = ""; }; 428338422BA1BAFB004798C2 /* Spaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Spaces.swift; sourceTree = ""; }; 4285C5502D355F9900DADE45 /* WidgetCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCreationView.swift; sourceTree = ""; }; 4285C5522D35658000DADE45 /* TileCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileCard.swift; sourceTree = ""; }; + 428626022DA5CCAE00D58D13 /* CollapsibleViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleViewTests.swift; sourceTree = ""; }; + 428626032DA5CCAE00D58D13 /* ExternalLinkButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalLinkButtonTests.swift; sourceTree = ""; }; + 428626042DA5CCAE00D58D13 /* HAButtonStylesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAButtonStylesTests.swift; sourceTree = ""; }; + 428626082DA5CD1B00D58D13 /* CornerRadiusSizesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadiusSizesTests.swift; sourceTree = ""; }; + 428626092DA5CD1B00D58D13 /* SpacesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesTests.swift; sourceTree = ""; }; + 4286260B2DA5CD1B00D58D13 /* WebhookSensorIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookSensorIdTests.swift; sourceTree = ""; }; 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHomeView.swift; sourceTree = ""; }; 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHomeViewModel.swift; sourceTree = ""; }; 4289DDAE2C85D5C4003591C2 /* ControlScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlScene.swift; sourceTree = ""; }; @@ -2249,7 +2293,7 @@ 42E6C08B2CE4F7A8007CA622 /* DownloadManagerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerViewModel.swift; sourceTree = ""; }; 42E95C542CA44FC90010ECE3 /* SafariWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebView.swift; sourceTree = ""; }; 42E95C562CA45EFA0010ECE3 /* OnboardingErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingErrorView.swift; sourceTree = ""; }; - 42E95C582CA46AD50010ECE3 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; + 42E95C582CA46AD50010ECE3 /* ShareActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActivityView.swift; sourceTree = ""; }; 42E9AFFE2CE63944009DDA46 /* AudioOutputSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputSensor.swift; sourceTree = ""; }; 42EB03092C6E4D0E00A184A6 /* WatchMagicViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMagicViewRow.swift; sourceTree = ""; }; 42EF0ACC2D4CDC0C0088C91E /* ResetAllCustomWidgetConfirmationAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetAllCustomWidgetConfirmationAppIntent.swift; sourceTree = ""; }; @@ -2343,7 +2387,6 @@ B086E41966E89AE531E3C1A5 /* Pods-iOS-Extensions-Widgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.debug.xcconfig"; sourceTree = ""; }; B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; sourceTree = ""; }; B6022212226DAC9D00E8DBFE /* ScaledFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaledFont.swift; sourceTree = ""; }; - B6022222226DBA3800E8DBFE /* OnboardingNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNavigationViewController.swift; sourceTree = ""; }; B60247ED1FBD21C600998205 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; B60247FF1FBD343000998205 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; B60248011FBD349000998205 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2571,9 +2614,7 @@ B661FB67226B961400E541DD /* WebSocketBridge.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = WebSocketBridge.js; sourceTree = ""; }; B661FB69226BBDA900E541DD /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; B661FB6E226BCCAD00E541DD /* ConnectionSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsViewController.swift; sourceTree = ""; }; - B661FB79226C197900E541DD /* OnboardingManualURLViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManualURLViewController.swift; sourceTree = ""; }; B661FC7D226C87BB00E541DD /* home.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = home.json; sourceTree = ""; }; - B661FC87226D478300E541DD /* OnboardingScanningViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScanningViewController.swift; sourceTree = ""; }; B66C58A5215086F0004AB261 /* HomeAssistant-Extensions-Intents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "HomeAssistant-Extensions-Intents.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; B66C58A7215086F0004AB261 /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; B66C58A9215086F0004AB261 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2929,6 +2970,9 @@ children = ( B657A8E81CA646EB00121384 /* App */, FD3BC66429BA000A00B19FBE /* CarPlay */, + 42A47A8E2C4548BC00C9B43D /* Improv */, + 42FCCFF72B9B1C310057783F /* Thread */, + 42462E6A2DA3CF8500ECC8A7 /* Watch */, 111501A72528412C00DCFA94 /* Extensions */, 11DE9D8425B6103C0081C0ED /* Launcher */, 1167402325198F9A00F51626 /* MacBridge */, @@ -2959,6 +3003,7 @@ 111501A82528414000DCFA94 /* Tests */ = { isa = PBXGroup; children = ( + 42196AD12DA5AF3D00BD501E /* Mocks */, 420E64B72D676A4200A31E86 /* Widgets */, B657A8FF1CA646EB00121384 /* App */, D03D894320E0BC1800D4F28D /* Shared */, @@ -3096,16 +3141,12 @@ 1168BF35271811C200DD4D15 /* Screens */ = { isa = PBXGroup; children = ( - 429821122CD0DD71005ECD39 /* Bluetooth */, - B661FC87226D478300E541DD /* OnboardingScanningViewController.swift */, - 11DA6B4E2713912F008ADFAF /* OnboardingPermissionViewController.swift */, - B661FB79226C197900E541DD /* OnboardingManualURLViewController.swift */, - 11E99A4F27156854003C8A65 /* OnboardingTerminalViewController.swift */, - 42E95C562CA45EFA0010ECE3 /* OnboardingErrorView.swift */, 426EE49A2CA4194E00A5EF4F /* OnboardingWelcomeView.swift */, - 42E95C582CA46AD50010ECE3 /* ActivityView.swift */, + 427FEE052D9C03C50047C00C /* OnboardingServersList */, + 427FEE642D9EBC340047C00C /* OnboardingPermissions */, + 42E95C562CA45EFA0010ECE3 /* OnboardingErrorView.swift */, + 42E95C582CA46AD50010ECE3 /* ShareActivityView.swift */, 42E95C542CA44FC90010ECE3 /* SafariWebView.swift */, - 42DF6B2C2CCF8A2200D7EC14 /* PermissionRequestView.swift */, ); path = Screens; sourceTree = ""; @@ -3113,8 +3154,8 @@ 1168BF36271811D800DD4D15 /* Container */ = { isa = PBXGroup; children = ( - B6022222226DBA3800E8DBFE /* OnboardingNavigationViewController.swift */, - 11DA6B4C2713900E008ADFAF /* OnboardingPermissionWorkflowController.swift */, + 427FEE552D9D39A50047C00C /* OnboardingNavigationView.swift */, + 427FEE622D9EA1400047C00C /* OnboardingNavigationViewModel.swift */, ); path = Container; sourceTree = ""; @@ -3438,7 +3479,6 @@ 11AF4D24249D1931006C74C0 /* LastUpdateSensor.swift */, 11AF4D18249C8253006C74C0 /* PedometerSensor.swift */, 119385A3249E8E360097F497 /* StorageSensor.swift */, - 1109F81E24A1C011002590F2 /* SensorProvider.swift */, B613936824F728F8002B8C5D /* InputOutputDeviceSensor.swift */, 11358AEB24FC9F300074C4E2 /* ActiveSensor.swift */, 110ED56225A563D600489AF7 /* DisplaySensor.swift */, @@ -3922,6 +3962,31 @@ path = AppIcon; sourceTree = ""; }; + 42196AD12DA5AF3D00BD501E /* Mocks */ = { + isa = PBXGroup; + children = ( + 42196AD02DA5AF3D00BD501E /* MockBonjour.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + 42196AD62DA5AF7A00BD501E /* ServersList */ = { + isa = PBXGroup; + children = ( + 42196AD52DA5AF7A00BD501E /* OnboardingServersListViewModelTests.swift */, + ); + path = ServersList; + sourceTree = ""; + }; + 42196AD92DA5AF7A00BD501E /* Onboarding */ = { + isa = PBXGroup; + children = ( + 42196ADD2DA5B58200BD501E /* OnboardingNavigationTests.swift */, + 42196AD62DA5AF7A00BD501E /* ServersList */, + ); + path = Onboarding; + sourceTree = ""; + }; 421B1C142BD65238001ED18C /* Widgets */ = { isa = PBXGroup; children = ( @@ -4025,6 +4090,23 @@ path = MagicItem; sourceTree = ""; }; + 42462E6A2DA3CF8500ECC8A7 /* Watch */ = { + isa = PBXGroup; + children = ( + 42B1A7442C1305C300904548 /* WatchCommunicatorService.swift */, + ); + path = Watch; + sourceTree = ""; + }; + 42462E6B2DA3D05500ECC8A7 /* PermissionScreen */ = { + isa = PBXGroup; + children = ( + 42DF6B2C2CCF8A2200D7EC14 /* PermissionRequestView.swift */, + 429821122CD0DD71005ECD39 /* Bluetooth */, + ); + path = PermissionScreen; + sourceTree = ""; + }; 4251AA972C6B9D30004CCC9D /* Edit */ = { isa = PBXGroup; children = ( @@ -4199,10 +4281,42 @@ path = Views; sourceTree = ""; }; + 427FEE052D9C03C50047C00C /* OnboardingServersList */ = { + isa = PBXGroup; + children = ( + 427FEE0C2D9C221D0047C00C /* ManualURLEntry */, + 427FEE022D9BE7690047C00C /* OnboardingServersListView.swift */, + 427FEE062D9C03DE0047C00C /* OnboardingScanningInstanceRow.swift */, + 427FEE082D9C04050047C00C /* OnboardingServersListViewModel.swift */, + 427FEE0A2D9C05EF0047C00C /* ServersScanAnimationView.swift */, + ); + path = OnboardingServersList; + sourceTree = ""; + }; + 427FEE0C2D9C221D0047C00C /* ManualURLEntry */ = { + isa = PBXGroup; + children = ( + 427FEE0D2D9C22310047C00C /* ManualURLEntryView.swift */, + ); + path = ManualURLEntry; + sourceTree = ""; + }; + 427FEE642D9EBC340047C00C /* OnboardingPermissions */ = { + isa = PBXGroup; + children = ( + 427FEE652D9EBC430047C00C /* OnboardingPermissionsNavigationView.swift */, + 42462E682D9ED75900ECC8A7 /* LocationPermissionView.swift */, + 42462E742DA53F7200ECC8A7 /* LocationPermissionViewModel.swift */, + ); + path = OnboardingPermissions; + sourceTree = ""; + }; 428338412BA1BAF3004798C2 /* Constants */ = { isa = PBXGroup; children = ( 428338422BA1BAFB004798C2 /* Spaces.swift */, + 42462E702DA4114800ECC8A7 /* Sizes.swift */, + 42462E762DA5421D00ECC8A7 /* CornerRadiusSizes.swift */, ); path = Constants; sourceTree = ""; @@ -4219,6 +4333,41 @@ path = Builder; sourceTree = ""; }; + 428626002DA5CC8400D58D13 /* DesignSystem */ = { + isa = PBXGroup; + children = ( + 428626012DA5CC8B00D58D13 /* Components */, + ); + path = DesignSystem; + sourceTree = ""; + }; + 428626012DA5CC8B00D58D13 /* Components */ = { + isa = PBXGroup; + children = ( + 428626022DA5CCAE00D58D13 /* CollapsibleViewTests.swift */, + 428626032DA5CCAE00D58D13 /* ExternalLinkButtonTests.swift */, + 428626042DA5CCAE00D58D13 /* HAButtonStylesTests.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4286260A2DA5CD1B00D58D13 /* Sizes */ = { + isa = PBXGroup; + children = ( + 428626082DA5CD1B00D58D13 /* CornerRadiusSizesTests.swift */, + 428626092DA5CD1B00D58D13 /* SpacesTests.swift */, + ); + path = Sizes; + sourceTree = ""; + }; + 4286260C2DA5CD1B00D58D13 /* Webhook */ = { + isa = PBXGroup; + children = ( + 4286260B2DA5CD1B00D58D13 /* WebhookSensorIdTests.swift */, + ); + path = Webhook; + sourceTree = ""; + }; 4289DDAC2C85D595003591C2 /* Scene */ = { isa = PBXGroup; children = ( @@ -4421,6 +4570,7 @@ 4254C4CC2D103F7B00245021 /* ExternalLinkButton.swift */, 42B74A5C2D36A47E00C37304 /* CloseButton.swift */, 42C131CF2D66084C00AF48E6 /* PillView.swift */, + 427FEE672D9ECFD70047C00C /* PrivacyNoteView.swift */, ); path = Components; sourceTree = ""; @@ -4992,24 +5142,22 @@ B657A8E81CA646EB00121384 /* App */ = { isa = PBXGroup; children = ( - 42B94BD92B9606CD00DEE060 /* Assist */, - 42FCCFDD2B9B1AB00057783F /* BarcodeScanner */, B657A8E91CA646EB00121384 /* AppDelegate.swift */, + 11A183B22511BCF300CA326A /* LifecycleManager.swift */, 422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */, - 42B1A7442C1305C300904548 /* WatchCommunicatorService.swift */, + 11EFCDD424F5FA7E00314D85 /* Scenes */, + 11AF1ED82528FBAA00AAE364 /* Onboarding */, + 11AD2E2A2528FDB700FBC437 /* WebView */, + 42462E6B2DA3D05500ECC8A7 /* PermissionScreen */, + 42B94BD92B9606CD00DEE060 /* Assist */, + 42FCCFDD2B9B1AB00057783F /* BarcodeScanner */, D03D893720E0AF1B00D4F28D /* ClientEvents */, - 42A47A8E2C4548BC00C9B43D /* Improv */, - 11A183B22511BCF300CA326A /* LifecycleManager.swift */, 117EB13E2569AD3000049541 /* Notifications */, - 11AF1ED82528FBAA00AAE364 /* Onboarding */, - B69933961E232AF50054453D /* Resources */, - 11EFCDD424F5FA7E00314D85 /* Scenes */, B661FB6B226BCC8500E541DD /* Settings */, 11B7ECD9274DA521009AD634 /* Servers */, - 42FCCFF72B9B1C310057783F /* Thread */, B679B1FA1E1F3D020071D366 /* Utilities */, - 11AD2E2A2528FDB700FBC437 /* WebView */, 11A71C6924A463EE00D9565F /* ZoneManager */, + B69933961E232AF50054453D /* Resources */, ); path = App; sourceTree = ""; @@ -5017,6 +5165,10 @@ B657A8FF1CA646EB00121384 /* App */ = { isa = PBXGroup; children = ( + 4286260A2DA5CD1B00D58D13 /* Sizes */, + 4286260C2DA5CD1B00D58D13 /* Webhook */, + 428626002DA5CC8400D58D13 /* DesignSystem */, + 42196AD92DA5AF7A00BD501E /* Onboarding */, 42E3B8BB2D8ACDC100F5D084 /* Extensions */, 46F103242D7214F7002BC586 /* ClientEvents */, 422E626A2CDCF00000987BD0 /* Area */, @@ -5215,6 +5367,8 @@ B6E91C212232482A0014CB8D /* Webhook */ = { isa = PBXGroup; children = ( + 42462E6C2DA4094300ECC8A7 /* WebhookSensorId.swift */, + 1109F81E24A1C011002590F2 /* SensorProvider.swift */, 11AF4D0F249C7DD8006C74C0 /* Sensors */, B6A258442232485300ADD202 /* Alamofire+EncryptedResponses.swift */, 1141182824AFA0F000E6525C /* Networking */, @@ -5385,6 +5539,7 @@ D0A6367420DBE93400E5C49B /* Realm+Initialization.swift */, D0DD2CED213BCA8900C3D9F7 /* URL+Extensions.swift */, D0EEF302214D8F0300D1D360 /* String+HA.swift */, + 427FEE572D9D48120047C00C /* View+HA.swift */, 42D996E42D89863A001737A0 /* Bool+HA.swift */, 42E3B8B82D8AC63300F5D084 /* Float+HA.swift */, D0EEF304214DD0D400D1D360 /* UIColor+HA.swift */, @@ -6716,10 +6871,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks.sh\"\n"; @@ -6905,10 +7064,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks.sh\"\n"; @@ -7030,10 +7193,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks.sh\"\n"; @@ -7047,10 +7214,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks.sh\"\n"; @@ -7246,7 +7417,7 @@ 425573E92B58396600145217 /* HAEntity+CarPlay.swift in Sources */, B68EDD05215F12C900DD6B28 /* NotificationActionConfigurator.swift in Sources */, 423B5E0D2D677BB90000CB95 /* WidgetContentMargin.swift in Sources */, - B616B299227ED68E00828165 /* Bonjour.swift in Sources */, + 42462E782DA5421D00ECC8A7 /* CornerRadiusSizes.swift in Sources */, 420E2AE62C474710004921D8 /* WidgetBasicButtonView.swift in Sources */, 11A48D7F24CA7E820021BDD9 /* Action+Observation.swift in Sources */, 11195F6B267EFB1F003DF674 /* NotificationManagerLocalPushInterface.swift in Sources */, @@ -7256,6 +7427,7 @@ B68EDD03215F0E2900DD6B28 /* NotificationCategoryConfigurator.swift in Sources */, 42D5ACCE2C636F2B00D9C4E2 /* WatchConfigurationViewModel.swift in Sources */, 117D8A0824A9347F00580913 /* UIColor+CSSRGB.swift in Sources */, + 427FEE562D9D39A50047C00C /* OnboardingNavigationView.swift in Sources */, 11F3D74C2495377B00C05BBA /* SensorListViewController.swift in Sources */, 42FCCFFA2B9B1C310057783F /* ThreadCredentialsSharingToKeychainViewModel.swift in Sources */, B6617EED1CFE79AD004DEE6D /* NSURL+QueryDictionary.swift in Sources */, @@ -7270,12 +7442,12 @@ 421155212D3525F500A71630 /* AppIconSelectorView.swift in Sources */, 42266B252B7A4BA900E94A71 /* BarcodeScannerViewModel.swift in Sources */, 11DE823024FAE66F00E636B8 /* UIWindow+Additions.swift in Sources */, - 11DA6B4F2713912F008ADFAF /* OnboardingPermissionViewController.swift in Sources */, 42FCCFFB2B9B1C310057783F /* ThreadTransferCredentialToHAViewModel.swift in Sources */, 4296C36E2B90DB640051B63C /* PerformAction.swift in Sources */, 1127383C2625512600F5E312 /* ButtonRowWithLoading.swift in Sources */, 42ABB0BB2C888BB10081461D /* CarPlayConfigurationViewModel.swift in Sources */, 42070EE82BAC43240031E96F /* AssistSession.swift in Sources */, + 427FEE092D9C04050047C00C /* OnboardingServersListViewModel.swift in Sources */, 420FE84E2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift in Sources */, 11195F71267EFE2C003DF674 /* NotificationManagerLocalPushInterfaceUnsupported.swift in Sources */, 428CB3372CF7FC0800F1320E /* WidgetFamilySizes.swift in Sources */, @@ -7286,8 +7458,8 @@ 1112AEBB25F717E9007A541A /* LocationHistoryDetailViewController.swift in Sources */, 11BD7B4D25B53D7F001826F0 /* AppMacBridgeStatusItemConfiguration.swift in Sources */, 11F55EED25D3B088003977AC /* NotificationDebugNotificationsViewController.swift in Sources */, - B6022223226DBA3800E8DBFE /* OnboardingNavigationViewController.swift in Sources */, 424123882CDCEB66007EDE70 /* AreaProvider.swift in Sources */, + 42462E732DA4114800ECC8A7 /* Sizes.swift in Sources */, 42F1DA6D2B4ED29C002729BC /* CarPlayPaginatedListTemplate.swift in Sources */, 11DA6B4B27137A60008ADFAF /* InputAccessoryView.swift in Sources */, 4278CB882D01F65300CFAAC9 /* AppleLikeListTopRowHeader.swift in Sources */, @@ -7321,6 +7493,7 @@ 426BB0C72D394BFF0062D905 /* AssistPipelinePicker.swift in Sources */, FD3BC66E29BA010A00B19FBE /* CarPlayDomainsListTemplate.swift in Sources */, 42D5ACD92C64C0E000D9C4E2 /* MagicItemAddView.swift in Sources */, + 42462E752DA53F7200ECC8A7 /* LocationPermissionViewModel.swift in Sources */, 425C5A072CF756DF00206B5B /* AssistMicAnimationView.swift in Sources */, 4285C5512D355F9900DADE45 /* WidgetCreationView.swift in Sources */, 1185DFAF271FF53800ED7D9A /* OnboardingAuthStepRegister.swift in Sources */, @@ -7358,7 +7531,6 @@ 4080D5C42C319B0A00099C88 /* WidgetDetailsAppIntent.swift in Sources */, 11A71C7124A4648000D9565F /* ZoneManagerEquatableRegion.swift in Sources */, 42FCD0132B9B29740057783F /* ThreadCredentialsManagementViewModel.swift in Sources */, - 11E99A5027156854003C8A65 /* OnboardingTerminalViewController.swift in Sources */, 422F951F2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift in Sources */, 1101568424D770B2009424C9 /* NFCWriter.swift in Sources */, 42B74A662D37C2C000C37304 /* WidgetCreationViewModel.swift in Sources */, @@ -7374,6 +7546,7 @@ 42EF0AD22D4D1A920088C91E /* UpdateWidgetItemConfirmationStateAppIntent.swift in Sources */, 42F1DA5B2B4BF7DF002729BC /* WindowSizeObserver.swift in Sources */, 429106892BA9D5F700D452F9 /* AssistView+Build.swift in Sources */, + 427FEE032D9BE7690047C00C /* OnboardingServersListView.swift in Sources */, 46CC96822D7136FF00F784CA /* Array+SafeSubscripting.swift in Sources */, 429BEA1A2D102F3A00F070F9 /* ConnectionErrorDetailsView.swift in Sources */, 11EFCDD624F5FA8D00314D85 /* WebViewSceneDelegate.swift in Sources */, @@ -7381,6 +7554,7 @@ 1185DF94271FBA6100ED7D9A /* OnboardingAuthDetails.swift in Sources */, 42BE698D2C4691EA00745ECA /* WebViewAccessoryViews.swift in Sources */, 4273C48B2C8858470065A5B4 /* ControlOpenPageValueProvider.swift in Sources */, + 427FEE072D9C03DE0047C00C /* OnboardingScanningInstanceRow.swift in Sources */, 420FE84B2B556BB100878E06 /* CarPlayActionsTemplate+Build.swift in Sources */, 42DF6B2F2CCF918D00D7EC14 /* BluetoothPermissionView.swift in Sources */, 425573C72B5572AD00145217 /* CarPlayServerListTemplate+Build.swift in Sources */, @@ -7400,15 +7574,16 @@ 420E2AE72C474718004921D8 /* WidgetBasicViewModel.swift in Sources */, 1178C4E524D5CEB200FDEC3E /* ConnectionURLViewController.swift in Sources */, 42F1DA6B2B4ED1BF002729BC /* CarPlayAreasZonesTemplate.swift in Sources */, - B661FC88226D478300E541DD /* OnboardingScanningViewController.swift in Sources */, + 427FEE0B2D9C05EF0047C00C /* ServersScanAnimationView.swift in Sources */, 11A48D8124CA8ADB0021BDD9 /* NotificationCategory+Observation.swift in Sources */, + 42462E692D9ED75900ECC8A7 /* LocationPermissionView.swift in Sources */, 42D0AE452D88259000D9715A /* WidgetDocumentationLink.swift in Sources */, 1100D51D2496AECE00B1073C /* PermissionStatusRow.swift in Sources */, 42E6C08A2CE4F4FA007CA622 /* DownloadManagerView.swift in Sources */, 42E95C572CA45EFA0010ECE3 /* OnboardingErrorView.swift in Sources */, B641BC1F1E2097EF002CCBC1 /* AboutViewController.swift in Sources */, 420C57C72D0A6DE700D2D9AC /* NoActiveURLView.swift in Sources */, - 42E95C592CA46AD50010ECE3 /* ActivityView.swift in Sources */, + 42E95C592CA46AD50010ECE3 /* ShareActivityView.swift in Sources */, B675ECC3221BB0E600C65D31 /* SearchPushRow.swift in Sources */, 11C05F2D254919210031D038 /* AccountInitialsImage.swift in Sources */, B605C891226E9DAC00EF46DD /* Permissions.swift in Sources */, @@ -7453,9 +7628,10 @@ 429821142CD0DD85005ECD39 /* BluetoothPermissionViewModel.swift in Sources */, 4251AA992C6B9D4C004CCC9D /* MagicItemCustomizationView.swift in Sources */, 1185DFB2271FF53800ED7D9A /* OnboardingAuthStepDuplicate.swift in Sources */, + 427FEE632D9EA1400047C00C /* OnboardingNavigationViewModel.swift in Sources */, 42F1DA5D2B4BF85F002729BC /* WindowScenesManager.swift in Sources */, 11EFCDDA24F5FE0600314D85 /* SceneActivity.swift in Sources */, - 11DA6B4D2713900E008ADFAF /* OnboardingPermissionWorkflowController.swift in Sources */, + 427FEE662D9EBC430047C00C /* OnboardingPermissionsNavigationView.swift in Sources */, 1185DFB1271FF53800ED7D9A /* OnboardingAuthStepNotify.swift in Sources */, 4278CB852D01F0B200CFAAC9 /* GesturesSetupViewModel.swift in Sources */, B65C0B522282BA13007E057B /* NotificationSettingsViewController.swift in Sources */, @@ -7465,7 +7641,7 @@ 425573CE2B5574F100145217 /* CarPlayAreasViewModel.swift in Sources */, 42BA1BC92C8864C200A2FC36 /* OpenPageAppIntent.swift in Sources */, 42333ADD2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */, - B661FB7A226C197900E541DD /* OnboardingManualURLViewController.swift in Sources */, + 427FEE0E2D9C22310047C00C /* ManualURLEntryView.swift in Sources */, 428D31A52D0B33AF0025B1D7 /* WidgetSensorsConfig.swift in Sources */, 119A827C252A3C4700D7000D /* NFCNDEFPayload+Additions.swift in Sources */, 42AC94A52CF872520050A62C /* TileCardStyleModifier.swift in Sources */, @@ -7505,6 +7681,9 @@ 11C95E3628BC20EA00171F1C /* OnboardingAuthLoginViewController.test.swift in Sources */, 42B942F82CAA1ECC00E36E02 /* PayloadConstants.test.swift in Sources */, 46C3EB1C2D721022009A893F /* Array+SafeSubscripting.test.swift in Sources */, + 4286260D2DA5CD1B00D58D13 /* CornerRadiusSizesTests.swift in Sources */, + 4286260E2DA5CD1B00D58D13 /* WebhookSensorIdTests.swift in Sources */, + 4286260F2DA5CD1B00D58D13 /* SpacesTests.swift in Sources */, 117D8A0A24A9381F00580913 /* UIColor+CSSRGB.test.swift in Sources */, 425573DA2B57DDE000145217 /* WindowScenesManager.test.swift in Sources */, 11ED43A027279AFA00B5FD45 /* OnboardingAuthLoginImpl.test.swift in Sources */, @@ -7512,14 +7691,20 @@ 42E3B8C02D8ACE0400F5D084 /* OptionalsDefaultTests.swift in Sources */, 42FCD0032B9B1CB70057783F /* ThreadCredentialsSharingViewModel.test.swift in Sources */, 42FCD0042B9B1CB70057783F /* ThreadCredentialsSharing.test.swift in Sources */, + 42196ADE2DA5B58200BD501E /* OnboardingNavigationTests.swift in Sources */, 4272B9A92CDCE15C008CC262 /* CarPlayConfig.test.swift in Sources */, 42D3E4A12C5BCD1100444BE6 /* WatchContext.test.swift in Sources */, + 428626052DA5CCAE00D58D13 /* CollapsibleViewTests.swift in Sources */, + 428626062DA5CCAE00D58D13 /* ExternalLinkButtonTests.swift in Sources */, + 428626072DA5CCAE00D58D13 /* HAButtonStylesTests.swift in Sources */, 42A818E32BBEA9780083D045 /* MockAudioRecorder.swift in Sources */, 11ED439A27265DE800B5FD45 /* OnboardingAuthStepRegister.test.swift in Sources */, + 42196AD22DA5AF3D00BD501E /* MockBonjour.swift in Sources */, 11EFD3C027261AA4000AF78B /* OnboardingAuthStepDuplicate.test.swift in Sources */, 42A818E52BBEAA3A0083D045 /* MockAudioPlayer.swift in Sources */, 11A71C9124A598AB00D9565F /* ZoneManagerProcessor.test.swift in Sources */, 11ED439827265B9C00B5FD45 /* OnboardingAuthStepNotify.test.swift in Sources */, + 42196ADA2DA5AF7A00BD501E /* OnboardingServersListViewModelTests.swift in Sources */, 42A818E72BBEAAE80083D045 /* MockAssistService.swift in Sources */, 420E64BA2D676A5800A31E86 /* WidgetsSnapshot.test.swift in Sources */, 11EFD3C327264306000AF78B /* UIAlertAction+Additions.swift in Sources */, @@ -7640,6 +7825,7 @@ 11B38EF5275C54A300205C7B /* GetCameraImageIntentHandler.swift in Sources */, 42CE8FB72B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */, 11521BBD25400284009C5C72 /* CrashReporter.swift in Sources */, + 42462E6E2DA4094300ECC8A7 /* WebhookSensorId.swift in Sources */, 424151FD2CD8F27100D7A6F9 /* CarPlayConfig.swift in Sources */, 113A8D4A283C7B1700B9DA32 /* PeriodicUpdateManager.swift in Sources */, 4264906C2C0F1B60002155CC /* AssistChatItem.swift in Sources */, @@ -7658,6 +7844,7 @@ B6B74CBC228398DD00D58A68 /* WKInterfaceDevice+Size.swift in Sources */, 11F20BC8274C60FF00DFB163 /* PushProviderConfiguration.swift in Sources */, B658AA7F2250B2A100C9BFE3 /* MobileAppUpdateRegistrationRequest.swift in Sources */, + 42462E792DA5421D00ECC8A7 /* CornerRadiusSizes.swift in Sources */, 11A48D7C24CA7D7F0021BDD9 /* NotificationAction.swift in Sources */, B67CE8BA22200F220034C1D0 /* AppConstants.swift in Sources */, 11B6774E28303D35006E9B1A /* SecurityExceptions.swift in Sources */, @@ -7717,6 +7904,7 @@ 1104FD06253292CD00B8BE34 /* Guarantee+Additions.swift in Sources */, B67CE8B722200F220034C1D0 /* UIImage+Icons.swift in Sources */, 1121CD4A27128A970071C2AA /* UIView+StackView.swift in Sources */, + 42462E712DA4114800ECC8A7 /* Sizes.swift in Sources */, 11B6B593294917E800B8B552 /* MatterWrapper.swift in Sources */, 11E1639B250B1B760076D612 /* OnboardingStateObservation.swift in Sources */, 115BC8292676F44E00452430 /* FocusSensor.swift in Sources */, @@ -7835,6 +8023,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 42462E722DA4114800ECC8A7 /* Sizes.swift in Sources */, 118F046924CB895A00CBBD5C /* UIColor+CSS3+Hex.swift in Sources */, 1109F81F24A1C011002590F2 /* SensorProvider.swift in Sources */, 4254C4CA2D103ABB00245021 /* ExternalLink.swift in Sources */, @@ -7861,6 +8050,7 @@ 42790C462C4808FA00E31B38 /* AppleLikeBottomSheet.swift in Sources */, B6723344225DBACF0031D629 /* AuthRequestMessage.swift in Sources */, D0DD2CEE213BCA8900C3D9F7 /* URL+Extensions.swift in Sources */, + 427FEE682D9ECFD70047C00C /* PrivacyNoteView.swift in Sources */, 11BA5EC92759AC0300FC40E8 /* XCGLogger+Export.swift in Sources */, 11B38EE4275C54A200205C7B /* FireEventIntentHandler.swift in Sources */, 1120C5842749C6350046C38B /* ServerProviding.swift in Sources */, @@ -7890,6 +8080,7 @@ D0BE440E210437F900C74314 /* AuthenticationRoutes.swift in Sources */, 11E5CF8124BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift in Sources */, 42FCCFA82B9A05400057783F /* View+RoundedCorner.swift in Sources */, + 427FEE592D9D48120047C00C /* View+HA.swift in Sources */, 1164D9DE25FB1B9800515E8A /* UIBarButtonItem+Additions.swift in Sources */, 11B38EEA275C54A200205C7B /* PickAServerError.swift in Sources */, B6B74CBD228399AB00D58A68 /* Action.swift in Sources */, @@ -7906,6 +8097,7 @@ 11C4629624B19FC700031902 /* URLSessionTask+WebhookPersisted.swift in Sources */, 11F2F25E25871D6000F61F7C /* NotificationAttachmentParserCamera.swift in Sources */, 11B63B0A2979A07000D908ED /* AssistIntentHandler.swift in Sources */, + 42196ACE2DA5A49600BD501E /* Bonjour.swift in Sources */, 1133F59C25F1DA5D00AD776F /* CLLocation+Sanitize.swift in Sources */, 11AF4D1C249C8AA0006C74C0 /* BatterySensor.swift in Sources */, D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */, @@ -8009,6 +8201,7 @@ 116570772702B0F6003906A7 /* DiskCache.swift in Sources */, 11657050270188E4003906A7 /* URLComponents+WidgetAuthenticity.swift in Sources */, B672334A225DDF410031D629 /* Event.swift in Sources */, + 42462E6D2DA4094300ECC8A7 /* WebhookSensorId.swift in Sources */, B6C091232151F90300A326DC /* LocationHistory.swift in Sources */, D0BE440A2104224600C74314 /* TokenInfo.swift in Sources */, 119EC3C724D5119300617D51 /* MobileAppConfigAction.swift in Sources */, @@ -8040,6 +8233,7 @@ D0EEF303214D8F0300D1D360 /* String+HA.swift in Sources */, 11B38EE6275C54A200205C7B /* CallServiceIntentHandler.swift in Sources */, 4254C4CD2D103F7B00245021 /* ExternalLinkButton.swift in Sources */, + 42462E772DA5421D00ECC8A7 /* CornerRadiusSizes.swift in Sources */, 491E98FF25D543560077BBE3 /* LogbookEntry.swift in Sources */, 3997926A2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */, 42CE8FA72B45D1E900C707F9 /* CoreStrings.swift in Sources */, @@ -8495,7 +8689,6 @@ B657A8F51CA646EB00121384 /* Base */, ); name = LaunchScreen.storyboard; - path = .; sourceTree = ""; }; B678DB351EA9999C0045312F /* MainInterface.storyboard */ = { @@ -8545,7 +8738,6 @@ 42F1DA672B4D993B002729BC /* bg */, ); name = Localizable.strings; - path = .; sourceTree = ""; }; B6CC5D842159D10D00833E5D /* Interface.storyboard */ = { diff --git a/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Debug.xcscheme b/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Debug.xcscheme index 6d053cd02..364f54144 100644 --- a/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Debug.xcscheme +++ b/HomeAssistant.xcodeproj/xcshareddata/xcschemes/App-Debug.xcscheme @@ -47,7 +47,8 @@ + skipped = "NO" + parallelizable = "NO"> + skipped = "NO" + parallelizable = "NO"> + skipped = "NO" + parallelizable = "NO"> + skipped = "NO" + parallelizable = "NO"> + skipped = "NO" + parallelizable = "NO"> UIViewController { - OnboardingPermissionViewControllerFactory.next(server: server) - } - func failureController(error: Error) -> UIViewController { UIHostingController(rootView: OnboardingErrorView(error: error)) } diff --git a/Sources/App/Onboarding/Container/OnboardingNavigationView.swift b/Sources/App/Onboarding/Container/OnboardingNavigationView.swift new file mode 100644 index 000000000..1aaa1e1bd --- /dev/null +++ b/Sources/App/Onboarding/Container/OnboardingNavigationView.swift @@ -0,0 +1,94 @@ +import Shared +import SwiftUI + +enum OnboardingStyle: Equatable { + enum RequiredType: Equatable { + case full + case permissions + } + + case initial + case required(RequiredType) + case secondary + + var insertsCancelButton: Bool { + switch self { + case .initial, .required: return false + case .secondary: return true + } + } +} + +enum OnboardingNavigation { + public static var requiredOnboardingStyle: OnboardingStyle? { + if Current.servers.all.isEmpty { + return .required(.full) + } else if !OnboardingPermissionHandler.notDeterminedPermissions.isEmpty { + return .required(.permissions) + } else { + return nil + } + } +} + +struct OnboardingNavigationView: View { + static func controller(onboardingStyle: OnboardingStyle) -> UIViewController { + OnboardingNavigationView(onboardingStyle: onboardingStyle).embeddedInHostingController() + } + + @Environment(\.dismiss) private var dismiss + @StateObject public var viewModel = OnboardingNavigationViewModel() + public let onboardingStyle: OnboardingStyle + + init(onboardingStyle: OnboardingStyle) { + self.onboardingStyle = onboardingStyle + } + + var body: some View { + NavigationView { + Group { + switch onboardingStyle { + case .initial: + OnboardingWelcomeView(shouldDismissOnboarding: $viewModel.shouldDismiss) + case .secondary: + OnboardingServersListView(shouldDismissOnboarding: $viewModel.shouldDismiss) + case let .required(type): + switch type { + case .full: + OnboardingWelcomeView(shouldDismissOnboarding: $viewModel.shouldDismiss) + case .permissions: + OnboardingPermissionsNavigationView(onboardingServer: nil) + } + } + } + .navigationViewStyle(.stack) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if onboardingStyle.insertsCancelButton { + Button(action: { + closeOnboarding() + }) { + Text(L10n.cancelLabel) + } + } + } + } + } + .navigationViewStyle(.stack) + .onChange(of: viewModel.shouldDismiss) { newValue in + if newValue { + closeOnboarding() + } + } + } + + private func closeOnboarding() { + if onboardingStyle == .secondary { + dismiss() + } else { + Current.sceneManager.webViewWindowControllerPromise.done { windowController in + windowController.setup() + } + } + } +} diff --git a/Sources/App/Onboarding/Container/OnboardingNavigationViewController.swift b/Sources/App/Onboarding/Container/OnboardingNavigationViewController.swift deleted file mode 100644 index 140165284..000000000 --- a/Sources/App/Onboarding/Container/OnboardingNavigationViewController.swift +++ /dev/null @@ -1,167 +0,0 @@ -import Eureka -import Shared -import SwiftUI -import UIKit - -enum OnboardingBarAppearance { - case normal - case hidden -} - -protocol OnboardingViewController { - var preferredBarAppearance: OnboardingBarAppearance { get } -} - -class OnboardingNavigationViewController: UINavigationController, RowControllerType { - enum OnboardingStyle { - enum RequiredType { - case full - case permissions - } - - case initial - case required(RequiredType) - case secondary - - var insertsCancelButton: Bool { - switch self { - case .initial, .required: return false - case .secondary: return true - } - } - - var modalPresentationStyle: UIModalPresentationStyle { - switch self { - case .initial, .required: return .fullScreen - case .secondary: - return .automatic - } - } - } - - public static var requiredOnboardingStyle: OnboardingStyle? { - if Current.servers.all.isEmpty { - return .required(.full) - } else if OnboardingPermissionViewControllerFactory.hasControllers { - return .required(.permissions) - } else { - return nil - } - } - - public let onboardingStyle: OnboardingStyle - public var onDismissCallback: ((UIViewController) -> Void)? - - public init(onboardingStyle: OnboardingStyle) { - self.onboardingStyle = onboardingStyle - - let rootViewController: UIViewController - - let onboardingWelcomeViewController = UIHostingController(rootView: OnboardingWelcomeView()) - - switch onboardingStyle { - case .initial: rootViewController = onboardingWelcomeViewController - case .secondary: rootViewController = OnboardingScanningViewController() - case let .required(type): - switch type { - case .full: - rootViewController = onboardingWelcomeViewController - case .permissions: - rootViewController = OnboardingPermissionViewControllerFactory.next(server: nil) - } - } - - super.init(rootViewController: rootViewController) - modalPresentationStyle = onboardingStyle.modalPresentationStyle - - if onboardingStyle.insertsCancelButton { - var leftItems = rootViewController.navigationItem.leftBarButtonItems ?? [] - leftItems.append( - UIBarButtonItem( - barButtonSystemItem: .cancel, - target: self, - action: #selector(cancelTapped(_:)) - ) - ) - rootViewController.navigationItem.leftBarButtonItems = leftItems - } - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - delegate = self - view.tintColor = Asset.Colors.haPrimary.color - navigationController?.navigationBar.tintColor = Asset.Colors.haPrimary.color - - let appearance = with(UINavigationBarAppearance()) { - $0.configureWithOpaqueBackground() - $0.backgroundColor = .systemBackground - $0.shadowColor = .clear - $0.titleTextAttributes = [.foregroundColor: UIColor.label] - } - navigationBar.standardAppearance = appearance - navigationBar.scrollEdgeAppearance = appearance - navigationBar.tintColor = .label - } - - @objc private func cancelTapped(_ sender: UIBarButtonItem) { - dismiss() - } - - func dismiss() { - dismiss(animated: true, completion: nil) - onDismissCallback?(self) - } - - override func show(_ vc: UIViewController, sender: Any?) { - if vc is OnboardingTerminalViewController { - Current.onboardingObservation.complete() - dismiss() - } else if sender as? UIViewController == topViewController { - super.show(vc, sender: sender) - } else { - Current.Log.error("unknown sender \(String(describing: sender)) wanted us to present: \(vc)") - } - } -} - -extension OnboardingNavigationViewController: UINavigationControllerDelegate { - private func updateNavigationBar(for controller: UIViewController?, animated: Bool) { - let appearance: OnboardingBarAppearance - - if let controller = controller as? OnboardingViewController { - appearance = controller.preferredBarAppearance - } else { - appearance = .normal - } - - switch appearance { - case .normal: - setNavigationBarHidden(false, animated: animated) - case .hidden: - setNavigationBarHidden(true, animated: animated) - } - } - - func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - updateNavigationBar(for: viewController, animated: animated) - - transitionCoordinator?.animate(alongsideTransition: { _ in - // putting the navigation bar change here causes the bar to animate in/out - }, completion: { [weak self] context in - if context.isCancelled { - self?.updateNavigationBar(for: self?.topViewController, animated: animated) - } - }) - } -} diff --git a/Sources/App/Onboarding/Container/OnboardingNavigationViewModel.swift b/Sources/App/Onboarding/Container/OnboardingNavigationViewModel.swift new file mode 100644 index 000000000..abd82edb1 --- /dev/null +++ b/Sources/App/Onboarding/Container/OnboardingNavigationViewModel.swift @@ -0,0 +1,23 @@ +import Foundation +import Shared + +final class OnboardingNavigationViewModel: ObservableObject { + @Published var shouldDismiss: Bool = false + + init() { + Current.onboardingObservation.register(observer: self) + } + + deinit { + Current.onboardingObservation.unregister(observer: self) + } +} + +extension OnboardingNavigationViewModel: OnboardingStateObserver { + func onboardingStateDidChange(to state: OnboardingState) { + guard state == .complete else { return } + DispatchQueue.main.async { [weak self] in + self?.shouldDismiss = true + } + } +} diff --git a/Sources/App/Onboarding/Container/OnboardingPermissionWorkflowController.swift b/Sources/App/Onboarding/Container/OnboardingPermissionWorkflowController.swift deleted file mode 100644 index a80736bac..000000000 --- a/Sources/App/Onboarding/Container/OnboardingPermissionWorkflowController.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Shared -import UIKit - -class OnboardingPermissionViewControllerFactory { - static var hasControllers: Bool { - !permissions.isEmpty - } - - static func next(server: Server?) -> UIViewController { - if let permission = permissions.first { - return OnboardingPermissionViewController(server: server, permission: permission, factory: self) - } else { - return OnboardingTerminalViewController() - } - } - - private static var permissions: [PermissionType] { - var permissions: [PermissionType] = [ - .notification, - .location, - ] - - if Current.motion.isActivityAvailable() { - permissions.append(.motion) - } - - if Current.focusStatus.isAvailable() { - permissions.append(.focus) - } - - return permissions.filter { $0.status == .notDetermined } - } -} diff --git a/Sources/App/Onboarding/Screens/OnboardingErrorView.swift b/Sources/App/Onboarding/Screens/OnboardingErrorView.swift index 4528673fe..c16c6c9d6 100644 --- a/Sources/App/Onboarding/Screens/OnboardingErrorView.swift +++ b/Sources/App/Onboarding/Screens/OnboardingErrorView.swift @@ -23,15 +23,20 @@ struct OnboardingErrorView: View { VStack { exportLogsView Button(L10n.Onboarding.ConnectionError.moreInfoButton) { - openURL(documentationURL(for: error)) + if let url = documentationURL(for: error) { + openURL(url) + } else { + Current.Log.error("Failed to create documentation URL for error: \(error)") + } } + .buttonStyle(.secondaryButton) } } .onAppear { viewAppeared = true } .sheet(isPresented: $showShareSheet, content: { - ActivityView(activityItems: [Current.Log.archiveURL()]) + ShareActivityView(activityItems: [Current.Log.archiveURL()]) }) } @@ -64,6 +69,7 @@ struct OnboardingErrorView: View { ForEach(errorComponents, id: \.self) { error in Text(AttributedString(error)) .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) } } .padding() @@ -75,27 +81,29 @@ struct OnboardingErrorView: View { @ViewBuilder private var exportLogsView: some View { - if #available(iOS 16.0, *) { - if let archiveURL = Current.Log.archiveURL() { + Group { + if #available(iOS 16.0, *), let archiveURL = Current.Log.archiveURL() { ShareLink(item: archiveURL, label: { Text(Current.Log.exportTitle) }) - } - } else { - Button(Current.Log.exportTitle) { - showShareSheet = true + } else { + Button(Current.Log.exportTitle) { + showShareSheet = true + } } } + .buttonStyle(.primaryButton) + .padding(.horizontal) } - private func documentationURL(for error: Error) -> URL { - var string = "https://companion.home-assistant.io/docs/troubleshooting/errors" + private func documentationURL(for error: Error) -> URL? { + var string = AppConstants.WebURLs.companionAppDocsTroubleshooting.absoluteString if let error = error as? OnboardingAuthError { string += "#\(error.kind.documentationAnchor)" } - return URL(string: string)! + return URL(string: string) } } diff --git a/Sources/App/Onboarding/Screens/OnboardingManualURLViewController.swift b/Sources/App/Onboarding/Screens/OnboardingManualURLViewController.swift deleted file mode 100644 index f8f3e321b..000000000 --- a/Sources/App/Onboarding/Screens/OnboardingManualURLViewController.swift +++ /dev/null @@ -1,301 +0,0 @@ -import PromiseKit -import Shared -import UIKit - -class OnboardingManualURLViewController: UIViewController, UITextFieldDelegate { - private let urlField = UITextField() - private var connectButton: UIButton? - private var connectLoading: UIActivityIndicatorView? - private var scrollView: UIScrollView? - private var bottomSpacer: UIView? - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - urlField.becomeFirstResponder() - } - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - - let (scrollView, stackView, equalSpacers) = UIView.contentStackView(in: view, scrolling: true) - self.scrollView = scrollView - - stackView.addArrangedSubview(with(UILabel()) { - $0.text = L10n.Onboarding.ManualSetup.title - Current.style.onboardingTitle($0) - }) - - stackView.addArrangedSubview(with(UILabel()) { - $0.text = L10n.Onboarding.ManualSetup.description - $0.font = .preferredFont(forTextStyle: .body) - $0.textColor = Asset.Colors.haPrimary.color - $0.textAlignment = .natural - $0.numberOfLines = 0 - }) - - stackView.addArrangedSubview(with(urlField) { - $0.delegate = self - $0.backgroundColor = UIColor(white: 0, alpha: 0.12) - $0.borderStyle = .roundedRect - $0.placeholder = "http://homeassistant.local:8123" - $0.textContentType = .URL - $0.keyboardType = .URL - $0.autocapitalizationType = .none - $0.autocorrectionType = .no - $0.spellCheckingType = .no - $0.smartDashesType = .no - $0.smartQuotesType = .no - $0.keyboardAppearance = .dark - $0.returnKeyType = .continue - $0.enablesReturnKeyAutomatically = true - $0.clearButtonMode = .whileEditing - - let font = UIFont.preferredFont(forTextStyle: .body) - $0.font = font - $0.heightAnchor.constraint(greaterThanOrEqualToConstant: font.lineHeight * 2.5) - .isActive = true - - NotificationCenter.default.addObserver( - self, - selector: #selector(updateConnectButton), - name: UITextField.textDidChangeNotification, - object: $0 - ) - }) - - switch traitCollection.userInterfaceIdiom { - case .pad, .mac: - urlField.widthAnchor.constraint(equalTo: stackView.readableContentGuide.widthAnchor) - .isActive = true - default: - urlField.widthAnchor.constraint(equalTo: stackView.layoutMarginsGuide.widthAnchor) - .isActive = true - } - - let button = with(UIButton(type: .custom)) { - $0.setTitle(L10n.Onboarding.ManualSetup.connect, for: .normal) - $0.addTarget(self, action: #selector(connectTapped(_:)), for: .touchUpInside) - Current.style.onboardingButtonPrimary($0) - - $0.translatesAutoresizingMaskIntoConstraints = false - $0.setContentCompressionResistancePriority(.required, for: .vertical) - } - let loading: UIActivityIndicatorView = { - let indicator: UIActivityIndicatorView - indicator = UIActivityIndicatorView(style: .medium) - - indicator.hidesWhenStopped = true - indicator.color = button.titleColor(for: .normal) - return indicator - }() - - connectButton = button - connectLoading = loading - - with(button) { - $0.addSubview(loading) - loading.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - loading.centerYAnchor.constraint(equalTo: $0.centerYAnchor), - loading.trailingAnchor.constraint(equalTo: $0.trailingAnchor, constant: -16), - ]) - } - - if Current.isCatalyst { - // iPad and iPhone unconditionally show the input view, but mac never does - stackView.addArrangedSubview(button) - } else { - urlField.inputAccessoryView = with(InputAccessoryView()) { - $0.directionalLayoutMargins = stackView.directionalLayoutMargins - $0.contentView = button - } - } - - stackView.addArrangedSubview(with(equalSpacers.next()) { - bottomSpacer = $0 - }) - - updateConnectButton() - - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillChangeFrame(_:)), - name: UIResponder.keyboardWillChangeFrameNotification, - object: nil - ) - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - connect() - return false - } - - func textField( - _ textField: UITextField, - shouldChangeCharactersIn range: NSRange, - replacementString string: String - ) -> Bool { - !isConnecting - } - - @objc private func connectTapped(_ sender: UIButton) { - Current.Log.verbose("Connect button tapped") - connect() - } - - @objc private func updateConnectButton() { - connectButton?.isEnabled = urlField.text?.isEmpty == false - } - - private var isConnecting: Bool = false { - didSet { - if isConnecting { - connectLoading?.startAnimating() - connectButton?.isUserInteractionEnabled = false - } else { - connectLoading?.stopAnimating() - connectButton?.isUserInteractionEnabled = true - } - } - } - - private func connect() { - guard !isConnecting else { return } - - isConnecting = true - - if let urlString = urlField.text { - urlField.text = removeTrailingSlash(from: urlString) - } - - let authentication = OnboardingAuth() - - firstly { - validatedURL(from: urlField.text) - }.recover { [self] error -> Promise in - Current.Log.error("Couldn't make a URL: \(error)") - - let alert = UIAlertController( - title: L10n.Onboarding.ManualSetup.CouldntMakeUrl.title, - message: L10n.Onboarding.ManualSetup.CouldntMakeUrl.message(urlField.text ?? ""), - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: L10n.okLabel, style: UIAlertAction.Style.default, handler: nil)) - present(alert, animated: true, completion: nil) - - return .init(error: PMKError.cancelled) - }.then { [self] (url: URL) -> Promise in - Current.Log.verbose("Onboard URL is \(url)") - // Removing path such as "/lovelace/home" to avoid wrong redirect after login - let url = URL(string: url.absoluteString.replacingOccurrences(of: url.path, with: "")) ?? url - Current.Log.verbose("Onboard URL after cleanup is \(url)") - - let instance = DiscoveredHomeAssistant(manualURL: url) - return authentication.authenticate(to: instance, sender: self) - }.ensure { [self] in - isConnecting = false - }.done { [self] server in - show(authentication.successController(server: server), sender: self) - }.catch { [self] error in - show(authentication.failureController(error: error), sender: self) - } - } - - enum ValidateError: Error, CancellableError { - case emptyString - case cannotConvert - case invalidScheme - case noSchemeCancelled - - var isCancelled: Bool { - switch self { - case .emptyString, .cannotConvert, .invalidScheme: - return false - case .noSchemeCancelled: - return true - } - } - } - - private func removeTrailingSlash(from string: String) -> String { - if string.hasSuffix("/") { - return String(string.dropLast()) - } else { - return string - } - } - - private func promptForScheme(for string: String) -> Promise { - Promise { seal in - let alert = UIAlertController( - title: L10n.Onboarding.ManualSetup.NoScheme.title, - message: L10n.Onboarding.ManualSetup.NoScheme.message, - preferredStyle: .actionSheet - ) - - with(alert.popoverPresentationController) { - $0?.sourceView = urlField - $0?.sourceRect = urlField.bounds - } - - func action(for scheme: String) -> UIAlertAction { - UIAlertAction(title: scheme, style: .default, handler: { _ in - seal.fulfill(scheme + string) - }) - } - - alert.addAction(action(for: "http://")) - alert.addAction(action(for: "https://")) - alert.addAction(UIAlertAction(title: L10n.cancelLabel, style: .cancel, handler: { _ in - seal.reject(ValidateError.noSchemeCancelled) - })) - self.present(alert, animated: true, completion: nil) - } - } - - private func validatedURL(from inputString: String?) -> Promise { - let start = Promise.value(inputString) - - return start - .map { (string: String?) -> String in - if let trimmed = string?.trimmingCharacters(in: .whitespacesAndNewlines), trimmed.isEmpty == false { - return trimmed - } else { - throw ValidateError.emptyString - } - }.then { (string: String) -> Promise in - if string.starts(with: "http://") || string.starts(with: "https://") { - return .value(string) - } else if string.contains("://") == false { - return self.promptForScheme(for: string) - } else { - throw ValidateError.invalidScheme - } - }.map { (string: String) -> URL in - if let url = URL(string: string) { - return url - } else { - throw ValidateError.cannotConvert - } - } - } - - @objc private func keyboardWillChangeFrame(_ note: Notification) { - guard let scrollView, - let frameValue = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { - return - } - - UIView.performWithoutAnimation { - view.layoutIfNeeded() - } - - let intersectHeight = view.convert(frameValue.cgRectValue, from: nil).intersection(scrollView.frame).height - let insetHeight = max(0, intersectHeight - (bottomSpacer?.bounds.height ?? 0)) - - scrollView.contentInset.bottom = insetHeight - scrollView.verticalScrollIndicatorInsets.bottom = insetHeight - } -} diff --git a/Sources/App/Onboarding/Screens/OnboardingPermissionViewController.swift b/Sources/App/Onboarding/Screens/OnboardingPermissionViewController.swift deleted file mode 100644 index 3d7aecac1..000000000 --- a/Sources/App/Onboarding/Screens/OnboardingPermissionViewController.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Shared -import UIKit - -class OnboardingPermissionViewController: UIViewController, OnboardingViewController { - let server: Server? - let permission: PermissionType - let factory: OnboardingPermissionViewControllerFactory.Type - - init(server: Server?, permission: PermissionType, factory: OnboardingPermissionViewControllerFactory.Type) { - self.server = server - self.permission = permission - self.factory = factory - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - var preferredBarAppearance: OnboardingBarAppearance { .hidden } - - private var headerImageView: UIImageView? - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - navigationItem.hidesBackButton = true - - let (_, stackView, equalSpacers) = UIView.contentStackView(in: view, scrolling: true) - - stackView.addArrangedSubview(with(UIImageView()) { - headerImageView = $0 - $0.image = permission.enableIcon.image(ofSize: CGSize(width: 128, height: 128), color: .black) - .withRenderingMode(.alwaysTemplate) - }) - - stackView.addArrangedSubview(with(UILabel()) { - $0.text = permission.title - Current.style.onboardingTitle($0) - }) - - let descriptionLabel = with(UILabel()) { - $0.text = permission.enableDescription - $0.font = .preferredFont(forTextStyle: .body) - $0.textColor = .secondaryLabel - $0.numberOfLines = 0 - $0.textAlignment = .center - } - stackView.addArrangedSubview(descriptionLabel) - stackView.setCustomSpacing(stackView.spacing * 2.0, after: descriptionLabel) - - for bulletPoint in permission.enableBulletPoints { - let view = with(UIStackView()) { - $0.axis = .horizontal - $0.alignment = .center - $0.spacing = 16.0 - $0.directionalLayoutMargins = .init(top: 0, leading: 16, bottom: 0, trailing: 16) - $0.isLayoutMarginsRelativeArrangement = true - - $0.addArrangedSubview(with(UIImageView()) { - $0.image = bulletPoint.0.image(ofSize: CGSize(width: 34, height: 34), color: .black) - .withRenderingMode(.alwaysTemplate) - $0.setContentCompressionResistancePriority(.required, for: .horizontal) - $0.setContentCompressionResistancePriority(.required, for: .vertical) - $0.setContentHuggingPriority(.required, for: .horizontal) - $0.setContentHuggingPriority(.required, for: .vertical) - }) - $0.addArrangedSubview(with(UILabel()) { - $0.text = bulletPoint.1 - $0.textColor = .label - $0.font = .boldSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize) - $0.numberOfLines = 0 - $0.setContentHuggingPriority(.defaultLow, for: .horizontal) - }) - } - - stackView.addArrangedSubview(view) - - switch traitCollection.userInterfaceIdiom { - case .pad, .mac: - view.widthAnchor.constraint(equalTo: stackView.readableContentGuide.widthAnchor) - .isActive = true - default: - view.widthAnchor.constraint(equalTo: stackView.layoutMarginsGuide.widthAnchor) - .isActive = true - } - } - - stackView.addArrangedSubview(equalSpacers.next()) - - stackView.addArrangedSubview(with(UIButton(type: .custom)) { - $0.setTitle(L10n.continueLabel, for: .normal) - $0.addTarget(self, action: #selector(continueTapped(_:)), for: .touchUpInside) - Current.style.onboardingButtonPrimary($0) - }) - - stackView.addArrangedSubview(with(UILabel()) { - $0.font = .preferredFont(forTextStyle: .footnote) - $0.textColor = Asset.Colors.haPrimary.color - $0.text = L10n.Onboarding.Permissions.changeLaterNote - $0.numberOfLines = 0 - $0.textAlignment = .center - }) - - updateHiddenStates() - } - - @objc private func continueTapped(_ sender: UIButton) { - sender.isUserInteractionEnabled = false - permission.request { [self] granted, _ in - if permission == .location, granted, let currentSSID = Current.connectivity.currentWiFiSSID() { - // update SSIDs if we have access to them, since we're gonna need it later - server?.info.connection.internalSSIDs = [currentSSID] - } - - sender.isUserInteractionEnabled = true - show(factory.next(server: server), sender: self) - } - } - - private func updateHiddenStates() { - let imageViewHidden = traitCollection.verticalSizeClass == .compact - headerImageView?.isHidden = imageViewHidden - } - - override func traitCollectionDidChange(_ traitCollection: UITraitCollection?) { - super.traitCollectionDidChange(traitCollection) - updateHiddenStates() - } -} diff --git a/Sources/App/Onboarding/Screens/OnboardingPermissions/LocationPermissionView.swift b/Sources/App/Onboarding/Screens/OnboardingPermissions/LocationPermissionView.swift new file mode 100644 index 000000000..b57e6c726 --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingPermissions/LocationPermissionView.swift @@ -0,0 +1,83 @@ +import Shared +import SwiftUI + +struct LocationPermissionView: View { + @StateObject private var viewModel = LocationPermissionViewModel() + let permission: PermissionType + let completeAction: () -> Void + + var body: some View { + VStack(spacing: Spaces.three) { + header + Spacer() + actionButtons + } + .frame(maxWidth: Sizes.maxWidthForLargerScreens) + .padding() + .alert( + L10n.Onboarding.Permission.Location.Deny.Alert.title, + isPresented: $viewModel.showDenyAlert, + actions: { + Button(L10n.continueLabel, role: .destructive) { + viewModel.requestLocationPermission() + } + }, + message: { + Text(verbatim: L10n.Onboarding.Permission.Location.Deny.Alert.message) + } + ) + .onChange(of: viewModel.shouldComplete) { newValue in + if newValue { + completeAction() + } + } + } + + @ViewBuilder + private var header: some View { + VStack(spacing: Spaces.two) { + Image(uiImage: permission.enableIcon.image( + ofSize: .init(width: 100, height: 100), + color: nil + ).withRenderingMode(.alwaysTemplate)) + .foregroundStyle(Color.asset(Asset.Colors.haPrimary)) + Text(verbatim: permission.title) + .font(.title.bold()) + Text(verbatim: L10n.Onboarding.Permission.Location.description) + .multilineTextAlignment(.center) + .opacity(0.5) + PrivacyNoteView(content: L10n.Onboarding.Permission.Location.privacyNote) + } + .frame(maxWidth: .infinity, alignment: .center) + } + + private var actionButtons: some View { + VStack(spacing: Spaces.one) { + Button { + viewModel.enableLocationSensor() + viewModel.requestLocationPermission() + } label: { + Text(L10n.Onboarding.Permission.Location.Buttons.allowAndShare) + } + .buttonStyle(.primaryButton) + Button { + viewModel.disableLocationSensor() + viewModel.requestLocationPermission() + } label: { + Text(L10n.Onboarding.Permission.Location.Buttons.allowForApp) + } + .buttonStyle(.primaryButton) + Button { + viewModel.disableLocationSensor() + viewModel.showDenyAlert = true + } label: { + Text(L10n.Onboarding.Permission.Location.Buttons.deny) + } + .buttonStyle(.secondaryNegativeButton) + } + } +} + +#Preview { + LocationPermissionView(permission: .location) {} +} diff --git a/Sources/App/Onboarding/Screens/OnboardingPermissions/LocationPermissionViewModel.swift b/Sources/App/Onboarding/Screens/OnboardingPermissions/LocationPermissionViewModel.swift new file mode 100644 index 000000000..3bcd5d302 --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingPermissions/LocationPermissionViewModel.swift @@ -0,0 +1,85 @@ +import CoreLocation +import Foundation +import Shared + +final class LocationPermissionViewModel: NSObject, ObservableObject { + @Published var showDenyAlert: Bool = false + @Published var shouldComplete: Bool = false + private let locationManager = CLLocationManager() + private var webhookSensors: [WebhookSensor] = [] + + private let sensorIdsToEnableDisable: [WebhookSensorId] = [ + .geocodedLocation, + .connectivityBSID, + .connectivitySSID, + ] + + override init() { + super.init() + Current.sensors.register(observer: self) + } + + func requestLocationPermission() { + locationManager.delegate = self + locationManager.requestWhenInUseAuthorization() + } + + func disableLocationSensor() { + for sensor in locationRelatedSensors() { + Current.sensors.setEnabled(false, for: sensor) + } + } + + func enableLocationSensor() { + for sensor in locationRelatedSensors() { + Current.sensors.setEnabled(true, for: sensor) + } + } + + private func locationRelatedSensors() -> [WebhookSensor] { + webhookSensors.filter { sensor in + sensorIdsToEnableDisable.map(\.rawValue).contains(sensor.UniqueID) + } + } +} + +extension LocationPermissionViewModel: SensorObserver { + func sensorContainer( + _ container: Shared.SensorContainer, + didSignalForUpdateBecause reason: Shared.SensorContainerUpdateReason + ) { + /* no-op */ + } + + func sensorContainer(_ container: SensorContainer, didUpdate update: SensorObserverUpdate) { + update.sensors.done { [weak self] sensors in + self?.webhookSensors = sensors + } + } +} + +extension LocationPermissionViewModel: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .notDetermined: + break + case .restricted: + break + case .denied: + break + case .authorizedAlways: + break + case .authorizedWhenInUse: + manager.requestAlwaysAuthorization() + case .authorized: + break + @unknown default: + break + } + + guard manager.authorizationStatus != .notDetermined else { return } + DispatchQueue.main.async { [weak self] in + self?.shouldComplete = true + } + } +} diff --git a/Sources/App/Onboarding/Screens/OnboardingPermissions/OnboardingPermissionsNavigationView.swift b/Sources/App/Onboarding/Screens/OnboardingPermissions/OnboardingPermissionsNavigationView.swift new file mode 100644 index 000000000..5a8849d5a --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingPermissions/OnboardingPermissionsNavigationView.swift @@ -0,0 +1,53 @@ +import Shared +import SwiftUI + +enum OnboardingPermissionHandler { + static var notDeterminedPermissions: [PermissionType] { + let permissions: [PermissionType] = [ + .location, + ] + + return permissions.filter { $0.status == .notDetermined } + } +} + +struct OnboardingPermissionsNavigationView: View { + let onboardingServer: Server? + + var body: some View { + Group { + if let permission = OnboardingPermissionHandler.notDeterminedPermissions.first, permission == .location { + LocationPermissionView(permission: permission) { + Current.onboardingObservation.complete() + } + } else { + flowEnd + } + } + .onDisappear { + Current.connectivity.syncNetworkInformation { + if let onboardingServer, let currentSSID = Current.connectivity.currentWiFiSSID() { + // Update SSIDs if we have access to them, since we're gonna need it later + onboardingServer.info.connection.internalSSIDs = [currentSSID] + } else { + Current.Log.verbose("No onboarding server or no SSID available") + } + } + } + } + + private var flowEnd: some View { + Image(systemSymbol: .checkmark) + .foregroundStyle(.green) + .font(.system(size: 100)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + Current.onboardingObservation.complete() + } + } + } +} + +#Preview { + OnboardingPermissionsNavigationView(onboardingServer: ServerFixture.standard) +} diff --git a/Sources/App/Onboarding/Screens/OnboardingScanningViewController.swift b/Sources/App/Onboarding/Screens/OnboardingScanningViewController.swift deleted file mode 100644 index 9c7668039..000000000 --- a/Sources/App/Onboarding/Screens/OnboardingScanningViewController.swift +++ /dev/null @@ -1,285 +0,0 @@ -import PromiseKit -import Shared -import UIKit - -class OnboardingScanningInstanceCell: UITableViewCell { - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) - with(textLabel) { - $0?.textColor = .label - $0?.numberOfLines = 0 - $0?.font = UIFont.boldSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize) - } - with(detailTextLabel) { - $0?.textColor = .secondaryLabel - $0?.numberOfLines = 0 - $0?.font = .preferredFont(forTextStyle: .body) - } - backgroundView = with(UIView()) { - $0.backgroundColor = .systemBackground - } - selectedBackgroundView = with(UIView()) { - $0.backgroundColor = .secondarySystemBackground - $0.layer.cornerRadius = 4.0 - } - backgroundColor = .clear - accessoryType = .disclosureIndicator - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public var isLoading: Bool = false { - didSet { - if isLoading { - accessoryType = .none - let activityIndicator: UIActivityIndicatorView = .init(style: .medium) - accessoryView = activityIndicator - activityIndicator.startAnimating() - } else { - accessoryView = nil - accessoryType = .disclosureIndicator - } - } - } -} - -class OnboardingScanningViewController: UIViewController { - private let discovery = Bonjour() - private var discoveredInstances: [DiscoveredHomeAssistant] = [] - - private var tableView: UITableView? - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - tableView?.indexPathsForSelectedRows?.forEach { indexPath in - tableView?.deselectRow(at: indexPath, animated: animated) - } - - discovery.start() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - discovery.stop() - } - - override func viewDidLoad() { - super.viewDidLoad() - - let activityIndicator: UIActivityIndicatorView - - activityIndicator = UIActivityIndicatorView(style: .medium) - - navigationItem.rightBarButtonItems = [ - UIBarButtonItem(customView: activityIndicator), - ] - - activityIndicator.startAnimating() - - let (_, stackView, _) = UIView.contentStackView(in: view, scrolling: false) - - view.backgroundColor = .systemBackground - title = L10n.Onboarding.Scanning.title - - stackView.addArrangedSubview(with(UILabel()) { - $0.text = L10n.Onboarding.Scanning.title - Current.style.onboardingTitle($0) - }) - - stackView.addArrangedSubview(with(UITableView(frame: .zero, style: .plain)) { - tableView = $0 - $0.delegate = self - $0.dataSource = self - $0.cellLayoutMarginsFollowReadableWidth = true - - $0.backgroundColor = .systemBackground - $0.backgroundView = with(UIView()) { - $0.backgroundColor = .systemBackground - } - - // hides the empty separators - $0.tableFooterView = UIView() - - $0.register(OnboardingScanningInstanceCell.self, forCellReuseIdentifier: "OnboardingScanningInstanceCell") - }) - - NSLayoutConstraint.activate([ - tableView!.widthAnchor.constraint(equalTo: stackView.layoutMarginsGuide.widthAnchor), - ]) - - let manualHintLabel: UILabel = with(UILabel()) { - $0.text = L10n.Onboarding.Scanning.manualHint - $0.textColor = Asset.Colors.haPrimary.color - $0.font = .preferredFont(forTextStyle: .footnote) - $0.numberOfLines = 1 - $0.baselineAdjustment = .alignCenters - $0.minimumScaleFactor = 0.2 - $0.adjustsFontSizeToFitWidth = true - } - stackView.addArrangedSubview(manualHintLabel) - stackView.setCustomSpacing(stackView.spacing / 2.0, after: manualHintLabel) - - stackView.addArrangedSubview(with(UIButton(type: .custom)) { - $0.setTitle(L10n.Onboarding.Scanning.manual, for: .normal) - $0.addTarget(self, action: #selector(didSelectManual(_:)), for: .touchUpInside) - Current.style.onboardingButtonSecondary($0) - }) - - discovery.observer = self - - if Current.appConfiguration == .debug { - for (idx, instance) in [ - DiscoveredHomeAssistant( - manualURL: URL(string: "https://jigsaw.w3.org/HTTP/Basic")!, - name: "Basic Auth" - ), - DiscoveredHomeAssistant( - manualURL: URL(string: "http://httpbin.org/digest-auth/asdf")!, - name: "Digest Auth" - ), - DiscoveredHomeAssistant( - manualURL: URL(string: "https://self-signed.badssl.com/")!, - name: "Self signed SSL" - ), - DiscoveredHomeAssistant( - manualURL: URL(string: "https://client.badssl.com/")!, - name: "Client Cert" - ), - DiscoveredHomeAssistant( - manualURL: URL(string: "https://expired.badssl.com/")!, - name: "Expired" - ), - DiscoveredHomeAssistant( - manualURL: URL(string: "https://httpbin.org/statuses/404")!, - name: "Status Code 404" - ), - ].enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500 * (idx + 1))) { [weak self] in - self?.add(discoveredInstance: instance) - } - } - } - } - - deinit { - discovery.stop() - } - - private func add(discoveredInstance: DiscoveredHomeAssistant) { - tableView?.performBatchUpdates({ - if let existing = discoveredInstances.firstIndex(where: { - ($0.uuid != nil && $0.uuid == discoveredInstance.uuid) - || $0.internalOrExternalURL == discoveredInstance.internalOrExternalURL - }) { - discoveredInstances[existing] = discoveredInstance - tableView?.reloadRows( - at: [IndexPath(row: existing, section: 0)], - with: .none - ) - } else { - UIAccessibility.post(notification: .announcement, argument: NSAttributedString( - string: L10n.Onboarding.Scanning.discoveredAnnouncement(discoveredInstance.locationName), - attributes: [.accessibilitySpeechQueueAnnouncement: true] - )) - discoveredInstances.append(discoveredInstance) - tableView?.insertRows( - at: [IndexPath(row: discoveredInstances.count - 1, section: 0)], - with: .automatic - ) - } - }, completion: nil) - } - - private func remove(forName name: String) { - tableView?.performBatchUpdates({ - if let existing = discoveredInstances.firstIndex(where: { - $0.bonjourName == name - }) { - discoveredInstances.remove(at: existing) - tableView?.deleteRows( - at: [IndexPath(row: existing, section: 0)], - with: .automatic - ) - } - }, completion: nil) - } - - @objc private func didSelectManual(_ sender: UIButton) { - navigationController?.pushViewController(OnboardingManualURLViewController(), animated: true) - } -} - -extension OnboardingScanningViewController: BonjourObserver { - func bonjour(_ bonjour: Bonjour, didAdd instance: DiscoveredHomeAssistant) { - add(discoveredInstance: instance) - } - - func bonjour(_ bonjour: Bonjour, didRemoveInstanceWithName name: String) { - remove(forName: name) - } -} - -extension OnboardingScanningViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let instance = discoveredInstances[indexPath.row] - let cell = tableView.cellForRow(at: indexPath) as? OnboardingScanningInstanceCell - - Current.Log.verbose("Selected row at \(indexPath.row) \(instance)") - - cell?.isLoading = true - tableView.isUserInteractionEnabled = false - - let authentication = OnboardingAuth() - - firstly { - authentication.authenticate(to: instance, sender: self) - }.ensure { - cell?.isLoading = false - tableView.isUserInteractionEnabled = true - tableView.deselectRow(at: indexPath, animated: true) - }.done { [self] server in - let controller = authentication.successController(server: server) - if controller is OnboardingTerminalViewController { - show(controller, sender: self) - } else { - navigationController?.pushViewController(controller, animated: true) - } - }.catch { [self] error in - navigationController?.pushViewController(authentication.failureController(error: error), animated: true) - } - } -} - -extension OnboardingScanningViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - discoveredInstances.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "OnboardingScanningInstanceCell", for: indexPath) - - let instance = discoveredInstances[indexPath.row] - - cell.textLabel?.text = instance.locationName - cell.detailTextLabel?.text = instance.internalOrExternalURL.absoluteString - cell.accessibilityLabel = instance.locationName - cell.accessibilityAttributedValue = with(NSMutableAttributedString()) { overall in - for part in [ - instance.internalOrExternalURL.host, - instance.internalOrExternalURL.port.flatMap { String(describing: $0) }, - ].compactMap({ $0 }) { - overall - .append(NSAttributedString( - string: part, - attributes: [.accessibilitySpeechPunctuation: true as NSNumber] - )) - overall.append(NSAttributedString(string: ", ")) - } - } - - return cell - } -} diff --git a/Sources/App/Onboarding/Screens/OnboardingServersList/ManualURLEntry/ManualURLEntryView.swift b/Sources/App/Onboarding/Screens/OnboardingServersList/ManualURLEntry/ManualURLEntryView.swift new file mode 100644 index 000000000..027241279 --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingServersList/ManualURLEntry/ManualURLEntryView.swift @@ -0,0 +1,103 @@ +import Shared +import SwiftUI + +struct ManualURLEntryView: View { + @Environment(\.dismiss) private var dismiss + @State private var urlString = "" + @FocusState private var focused: Bool? + @State private var showInvalidURLError = false + + let connectAction: (URL) -> Void + + var body: some View { + NavigationView { + List { + Section(L10n.Onboarding.ManualSetup.TextField.title) { + TextField(L10n.Onboarding.ManualSetup.TextField.placeholder, text: $urlString) + .keyboardType(.URL) + .focused($focused, equals: true) + .onAppear { + focused = true + } + } + + httpOrHttpsSection + } + .navigationTitle(L10n.Onboarding.ManualSetup.title) + .navigationViewStyle(.stack) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton { + dismiss() + } + } + } + .safeAreaInset(edge: .bottom) { + connectButton + } + .alert(isPresented: $showInvalidURLError) { + Alert( + title: Text(verbatim: L10n.Onboarding.ManualSetup.InputError.title), + message: Text(verbatim: L10n.Onboarding.ManualSetup.InputError.message), + dismissButton: .default(Text(verbatim: L10n.okLabel)) + ) + } + } + } + + // View which displays helpers to add http or https to the URL + @ViewBuilder + private var httpOrHttpsSection: some View { + let cleanedURL = urlString.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + // 7 is the count of http:// chars + let minCharsToActivateSection = 7 + if !cleanedURL.isEmpty, + !cleanedURL.starts(with: "http://"), + !cleanedURL.starts(with: "https://"), + cleanedURL.count >= minCharsToActivateSection { + Section(L10n.Onboarding.ManualSetup.HelperSection.title) { + HStack { + Button(action: { + urlString = "http://\(urlString)" + }, label: { + Text(verbatim: "http://\(urlString)") + }) + } + Button(action: { + urlString = "https://\(urlString)" + }, label: { + Text(verbatim: "https://\(urlString)") + }) + } + .buttonStyle(.pillButton) + .listRowBackground(Color.clear) + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowSeparator(.hidden) + } + } + + private var connectButton: some View { + Button { + if let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) { + dismiss() + connectAction(url) + } else { + showInvalidURLError = true + } + } label: { + Text(L10n.Onboarding.ManualSetup.connect) + } + .buttonStyle(.primaryButton) + .padding() + .disabled(urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } +} + +#Preview { + VStack {} + .sheet(isPresented: .constant(true)) { + ManualURLEntryView { _ in + } + } +} diff --git a/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingScanningInstanceRow.swift b/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingScanningInstanceRow.swift new file mode 100644 index 000000000..0918d8100 --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingScanningInstanceRow.swift @@ -0,0 +1,75 @@ +import Shared +import SwiftUI + +struct OnboardingScanningInstanceRow: View { + let name: String + let internalURLString: String? + let externalURLString: String? + let internalOrExternalURLString: String + let isLoading: Bool + + var body: some View { + HStack { + icon + VStack(alignment: .leading) { + Text(name) + .font(.headline) + Text(internalURLString ?? internalOrExternalURLString) + .font(.subheadline) + .foregroundColor(.secondary) + if let externalURLString { + Text(externalURLString) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if isLoading { + ProgressView() + } + } + .frame(maxWidth: .infinity) + } + + private var icon: some View { + ZStack { + Image(uiImage: Asset.SharedAssets.logo.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .padding(.trailing, Spaces.one) + if internalURLString == nil, externalURLString != nil { + Image(systemSymbol: .icloudCircleFill) + .foregroundStyle(Color(uiColor: Asset.Colors.haPrimary.color), .white) + .offset(x: 8, y: 12) + .shadow(color: .black.opacity(0.2), radius: 5) + } + } + } +} + +#Preview { + List { + OnboardingScanningInstanceRow( + name: "Home Assistant", + internalURLString: "https://example.com", + externalURLString: "https://example.com", + internalOrExternalURLString: "https://example.com", + isLoading: true + ) + OnboardingScanningInstanceRow( + name: "Home Assistant", + internalURLString: "https://example.com", + externalURLString: "https://example.com", + internalOrExternalURLString: "https://example.com", + isLoading: false + ) + OnboardingScanningInstanceRow( + name: "Home Assistant", + internalURLString: "https://example.com", + externalURLString: "https://example.com", + internalOrExternalURLString: "https://example.com", + isLoading: false + ) + } +} diff --git a/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListView.swift b/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListView.swift new file mode 100644 index 000000000..929eeed8c --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListView.swift @@ -0,0 +1,156 @@ +import Combine +import Shared +import SwiftUI + +struct OnboardingServersListView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var sizeClass + + @EnvironmentObject var hostingProvider: ViewControllerProvider + @StateObject private var viewModel = OnboardingServersListViewModel() + + @State private var showDocumentation = false + @State private var showManualInput = false + @State private var screenLoaded = false + + @Binding var shouldDismissOnboarding: Bool + + var body: some View { + List { + headerView + list + } + .animation(.easeInOut, value: viewModel.discoveredInstances.count) + .safeAreaInset(edge: .bottom) { + bottomButtons + } + .navigationTitle(L10n.Onboarding.Scanning.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar(content: { + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + .progressViewStyle(.circular) + .opacity(viewModel.isLoading ? 1 : 0) + .animation(.easeInOut, value: viewModel.isLoading) + } + }) + .onAppear { + onAppear() + } + .onDisappear { + onDisappear() + } + .sheet(isPresented: $viewModel.showError) { + errorView + } + .fullScreenCover(isPresented: .init(get: { + viewModel.showPermissionsFlow && viewModel.onboardingServer != nil + }, set: { newValue in + viewModel.showPermissionsFlow = newValue + })) { + OnboardingPermissionsNavigationView(onboardingServer: viewModel.onboardingServer) + } + } + + private func onAppear() { + if !screenLoaded { + screenLoaded = true + viewModel.startDiscovery() + } + } + + private func onDisappear() { + viewModel.stopDiscovery() + viewModel.currentlyInstanceLoading = nil + } + + @ViewBuilder + private var errorView: some View { + if let error = viewModel.error { + ConnectionErrorDetailsView( + server: nil, + error: error, + showSettingsEntry: false, + expandMoreDetails: true + ) + .onDisappear { + viewModel.resetFlow() + } + } else { + Text("Unmapped onboarding flow (1)") + } + } + + private var list: some View { + ForEach(viewModel.discoveredInstances, id: \.uuid) { instance in + if #available(iOS 17, *) { + Section { + serverRow(instance: instance) + } + .frame(minHeight: Current.isCatalyst ? 60 : nil) + .listSectionSpacing(.compact) + } else { + serverRow(instance: instance) + } + } + .disabled(viewModel.currentlyInstanceLoading != nil) + } + + private var headerView: some View { + Section { + ServersScanAnimationView() + .listRowBackground(Color.clear) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + private func serverRow(instance: DiscoveredHomeAssistant) -> some View { + Button(action: { + viewModel.selectInstance(instance, controller: hostingProvider.viewController) + }, label: { + OnboardingScanningInstanceRow( + name: instance.locationName, + internalURLString: instance.internalURL?.absoluteString, + externalURLString: instance.externalURL?.absoluteString, + internalOrExternalURLString: instance.internalOrExternalURL.absoluteString, + isLoading: instance == viewModel.currentlyInstanceLoading + ) + }) + .tint(Color(uiColor: .label)) + } + + private var bottomButtons: some View { + VStack { + VStack { + Button(action: { + showManualInput = true + }) { + Text(L10n.Onboarding.Scanning.manual) + } + .buttonStyle(.primaryButton) + .sheet(isPresented: $showManualInput) { + ManualURLEntryView { connectURL in + viewModel.isLoading = true + viewModel.selectInstance( + .init(manualURL: connectURL), + controller: hostingProvider.viewController + ) + } + } + Button(action: { + showDocumentation = true + }) { + Text(L10n.Onboarding.Servers.Docs.read) + } + .buttonStyle(.secondaryButton) + .fullScreenCover(isPresented: $showDocumentation) { + SafariWebView(url: AppConstants.WebURLs.homeAssistantGetStarted) + } + } + .frame(maxWidth: Sizes.maxWidthForLargerScreens) + } + .frame(maxWidth: .infinity) + .padding([.horizontal, .top]) + .background(.ultraThinMaterial) + } +} diff --git a/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListViewModel.swift b/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListViewModel.swift new file mode 100644 index 000000000..9fc61e251 --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingServersList/OnboardingServersListViewModel.swift @@ -0,0 +1,125 @@ +import Combine +import Foundation +import PromiseKit +import Shared +import SwiftUI + +final class OnboardingServersListViewModel: ObservableObject { + enum Destination { + case error(Error) + case next(Server) + } + + @Published var discoveredInstances: [DiscoveredHomeAssistant] = [] + @Published var currentlyInstanceLoading: DiscoveredHomeAssistant? + + @Published var showError = false + @Published var error: Error? + + @Published var showPermissionsFlow = false + @Published var onboardingServer: Server? + + /// Indicator for manual input loading + @Published var isLoading = false + + private var discovery = Current.bonjour() + private var cancellables = Set() + + init() { + discovery.observer = self + } + + func startDiscovery() { + discoveredInstances = [] + discovery.start() + + if Current.appConfiguration == .debug { + for (idx, instance) in [ + DiscoveredHomeAssistant( + manualURL: URL(string: "https://jigsaw.w3.org/HTTP/Basic")!, + name: "Basic Auth" + ), + DiscoveredHomeAssistant( + manualURL: URL(string: "http://httpbin.org/digest-auth/asdf")!, + name: "Digest Auth" + ), + DiscoveredHomeAssistant( + manualURL: URL(string: "https://self-signed.badssl.com/")!, + name: "Self signed SSL" + ), + DiscoveredHomeAssistant( + manualURL: URL(string: "https://client.badssl.com/")!, + name: "Client Cert" + ), + DiscoveredHomeAssistant( + manualURL: URL(string: "https://expired.badssl.com/")!, + name: "Expired" + ), + DiscoveredHomeAssistant( + manualURL: URL(string: "https://httpbin.org/statuses/404")!, + name: "Status Code 404" + ), + ].enumerated() { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500 * (idx + 1))) { [weak self] in + self?.discoveredInstances.append(instance) + } + } + } + } + + func stopDiscovery() { + discovery.stop() + } + + func selectInstance(_ instance: DiscoveredHomeAssistant, controller: UIViewController?) { + guard let controller else { + Current.Log.error("No controller provided for onboarding") + return + } + Current.Log.verbose("Selected instance \(instance)") + + currentlyInstanceLoading = instance + + let authentication = OnboardingAuth() + + authentication.authenticate(to: instance, sender: controller).pipe { [weak self] result in + DispatchQueue.main.async { + switch result { + case let .fulfilled(server): + Current.Log.verbose("Onboarding authentication succeeded") + self?.authenticationSucceeded(server: server) + case let .rejected(error): + self?.error = error + self?.showError = true + } + self?.isLoading = false + } + } + } + + func resetFlow() { + currentlyInstanceLoading = nil + isLoading = false + } + + @MainActor + private func authenticationSucceeded(server: Server) { + discovery.stop() + onboardingServer = server + showPermissionsFlow = true + } +} + +extension OnboardingServersListViewModel: BonjourObserver { + func bonjour(_ bonjour: Bonjour, didAdd instance: DiscoveredHomeAssistant) { + DispatchQueue.main.async { [weak self] in + self?.discoveredInstances.append(instance) + } + } + + func bonjour(_ bonjour: Bonjour, didRemoveInstanceWithName name: String) { + DispatchQueue.main.async { [weak self] in + self?.discoveredInstances.removeAll { $0.bonjourName == name } + } + } +} diff --git a/Sources/App/Onboarding/Screens/OnboardingServersList/ServersScanAnimationView.swift b/Sources/App/Onboarding/Screens/OnboardingServersList/ServersScanAnimationView.swift new file mode 100644 index 000000000..72df9cdb9 --- /dev/null +++ b/Sources/App/Onboarding/Screens/OnboardingServersList/ServersScanAnimationView.swift @@ -0,0 +1,38 @@ +import SFSafeSymbols +import Shared +import SwiftUI + +struct ServersScanAnimationView: View { + @State private var isAnimating = false + var body: some View { + VStack(spacing: Spaces.two) { + if #available(iOS 18, *) { + mainIcon + .symbolEffect(.pulse.byLayer, options: .repeat(.continuous)) + } else { + mainIcon + } + Text(L10n.Onboarding.Servers.Search.message) + .font(.footnote) + .foregroundStyle(.gray) + } + } + + private var mainIcon: some View { + Image(systemSymbol: .textMagnifyingglass) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + .foregroundColor(.gray) + .onAppear { + isAnimating = true + } + .onDisappear { + isAnimating = false + } + } +} + +#Preview { + ServersScanAnimationView() +} diff --git a/Sources/App/Onboarding/Screens/OnboardingTerminalViewController.swift b/Sources/App/Onboarding/Screens/OnboardingTerminalViewController.swift deleted file mode 100644 index d62a5b7b1..000000000 --- a/Sources/App/Onboarding/Screens/OnboardingTerminalViewController.swift +++ /dev/null @@ -1,5 +0,0 @@ -import UIKit - -class OnboardingTerminalViewController: UIViewController { - // this is a dummy class whose presentation ends onboarding -} diff --git a/Sources/App/Onboarding/Screens/OnboardingWelcomeView.swift b/Sources/App/Onboarding/Screens/OnboardingWelcomeView.swift index fd0e41ca9..c634f54ca 100644 --- a/Sources/App/Onboarding/Screens/OnboardingWelcomeView.swift +++ b/Sources/App/Onboarding/Screens/OnboardingWelcomeView.swift @@ -4,65 +4,81 @@ import SwiftUI struct OnboardingWelcomeView: View { @State private var showLearnMore = false + @State private var showLogo = false + @State private var showButtons = false + @State private var logoScale = 0.9 + @State private var buttonYOffset: CGFloat = 10 + + @Binding var shouldDismissOnboarding: Bool var body: some View { VStack(spacing: .zero) { Spacer() - logoBlock - textBlock + Group { + logoBlock + textBlock + } + .opacity(showLogo ? 1 : 0) + .scaleEffect(logoScale) Spacer() continueButton + .opacity(showButtons ? 1 : 0) + .offset(y: buttonYOffset) + } + .frame(maxWidth: Sizes.maxWidthForLargerScreens) + .onAppear { + withAnimation(.easeInOut(duration: 1.5)) { + showLogo = true + logoScale = 1.0 + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation(.easeInOut(duration: 0.5)) { + showButtons = true + buttonYOffset = 0 + } + } + } } - .frame(maxWidth: 600) .fullScreenCover(isPresented: $showLearnMore) { - SafariWebView(url: URL(string: "http://www.home-assistant.io")!) + SafariWebView(url: AppConstants.WebURLs.homeAssistantGetStarted) } } private var logoBlock: some View { - Image(uiImage: Asset.SharedAssets.logo.image) + Image(uiImage: Asset.SharedAssets.logoHorizontalText.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 300) .padding(.vertical, Spaces.four) } private var textBlock: some View { ScrollView { VStack(spacing: Spaces.two) { - Text(verbatim: L10n.Onboarding.Welcome.title(Current.device.systemName())) - .font(.title.bold()) - .frame(maxWidth: .infinity, alignment: .center) - .multilineTextAlignment(.center) Text(verbatim: L10n.Onboarding.Welcome.description) .foregroundStyle(Color(uiColor: .secondaryLabel)) - Button(L10n.Onboarding.Welcome.learnMore) { - showLearnMore = true - } - .tint(Color.asset(Asset.Colors.haPrimary)) } .padding() } } private var continueButton: some View { - NavigationLink(destination: OnboardingScanningView()) { - Text(verbatim: L10n.continueLabel) - .font(.callout.bold()) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .frame(height: 55) - .background(Color.asset(Asset.Colors.haPrimary)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - }.padding(Spaces.two) + VStack { + NavigationLink(destination: OnboardingServersListView(shouldDismissOnboarding: $shouldDismissOnboarding)) { + Text(verbatim: L10n.continueLabel) + } + .buttonStyle(.primaryButton) + .padding(.horizontal, Spaces.two) + Button(L10n.Onboarding.Welcome.getStarted) { + showLearnMore = true + } + .tint(Color.asset(Asset.Colors.haPrimary)) + .frame(minHeight: 40) + .buttonStyle(.secondaryButton) + } } } #Preview { - OnboardingWelcomeView() -} - -struct OnboardingScanningView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> UIViewController { - OnboardingScanningViewController() - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + OnboardingWelcomeView(shouldDismissOnboarding: .constant(false)) } diff --git a/Sources/App/Onboarding/Screens/ActivityView.swift b/Sources/App/Onboarding/Screens/ShareActivityView.swift similarity index 75% rename from Sources/App/Onboarding/Screens/ActivityView.swift rename to Sources/App/Onboarding/Screens/ShareActivityView.swift index 2352ca96b..107999870 100644 --- a/Sources/App/Onboarding/Screens/ActivityView.swift +++ b/Sources/App/Onboarding/Screens/ShareActivityView.swift @@ -1,11 +1,11 @@ import SwiftUI import UIKit -struct ActivityView: UIViewControllerRepresentable { +struct ShareActivityView: UIViewControllerRepresentable { var activityItems: [Any] var applicationActivities: [UIActivity]? = nil - func makeUIViewController(context: UIViewControllerRepresentableContext) + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { let controller = UIActivityViewController( activityItems: activityItems, @@ -16,6 +16,6 @@ struct ActivityView: UIViewControllerRepresentable { func updateUIViewController( _ uiViewController: UIActivityViewController, - context: UIViewControllerRepresentableContext + context: UIViewControllerRepresentableContext ) {} } diff --git a/Sources/App/Onboarding/Screens/Bluetooth/BluetoothPermissionView.swift b/Sources/App/PermissionScreen/Bluetooth/BluetoothPermissionView.swift similarity index 100% rename from Sources/App/Onboarding/Screens/Bluetooth/BluetoothPermissionView.swift rename to Sources/App/PermissionScreen/Bluetooth/BluetoothPermissionView.swift diff --git a/Sources/App/Onboarding/Screens/Bluetooth/BluetoothPermissionViewModel.swift b/Sources/App/PermissionScreen/Bluetooth/BluetoothPermissionViewModel.swift similarity index 100% rename from Sources/App/Onboarding/Screens/Bluetooth/BluetoothPermissionViewModel.swift rename to Sources/App/PermissionScreen/Bluetooth/BluetoothPermissionViewModel.swift diff --git a/Sources/App/Onboarding/Screens/PermissionRequestView.swift b/Sources/App/PermissionScreen/PermissionRequestView.swift similarity index 100% rename from Sources/App/Onboarding/Screens/PermissionRequestView.swift rename to Sources/App/PermissionScreen/PermissionRequestView.swift diff --git a/Sources/App/Resources/Assets.xcassets/accentColor.colorset/Contents.json b/Sources/App/Resources/Assets.xcassets/accentColor.colorset/Contents.json new file mode 100644 index 000000000..bac5a794b --- /dev/null +++ b/Sources/App/Resources/Assets.xcassets/accentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF4", + "green" : "0xA9", + "red" : "0x01" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 855215e0d..8958599a3 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -32,6 +32,7 @@ "actions_configurator.visual_section.scene_hint_footer" = "You can also change these by customizing the Scene attributes: %@"; "actions_configurator.visual_section.server_defined" = "The appearance of this action is controlled by the server configuration."; "addButtonLabel" = "Add"; +"privacyLabel" = "Privacy"; "alert.confirmation.delete_entities.message" = "This will clean your entities from database and it will only reload the next time you open the app from zero."; "alert.confirmation.generic.title" = "Are you sure?"; "alerts.action_automation_editor.unavailable.body" = "To automatically create an automation for an Action please update your Home Assistant to at least version 2024.2"; @@ -212,7 +213,7 @@ "connection.permission.internal_url.footer" = "If you still want to use the local URL and don't want to provide location permission, you can tap the button below, but please, be aware of the security risks."; "connection.permission.internal_url.ignore.alert.title" = "Are you sure?"; "connection.permission.internal_url.title" = "Permission access"; -"connection_error.advanced_section.title" = "Advanced"; +"connection_error.more_details_section.title" = "More details"; "connection_error.open_settings.title" = "Open settings"; "continue_label" = "Continue"; "copy_label" = "Copy"; @@ -439,6 +440,11 @@ Tags will work on any device with Home Assistant installed which has hardware su "onboarding.device_name_check.error.rename_action" = "Rename"; "onboarding.device_name_check.error.title" = "A device already exists with the name '%1$@'"; "onboarding.manual_setup.connect" = "Connect"; +"onboarding.manual_setup.input_error.title" = "Invalid URL"; +"onboarding.manual_setup.input_error.message" = "Make sure you have entered a valid URL."; +"onboarding.manual_setup.text_field.title" = "Your Home Assistant URL"; +"onboarding.manual_setup.text_field.placeholder" = "e.g. http://homeassistant.local:8123"; +"onboarding.manual_setup.helper_section.title" = "Did you mean..."; "onboarding.manual_setup.couldnt_make_url.message" = "The value '%@' was not a valid URL."; "onboarding.manual_setup.couldnt_make_url.title" = "Could not create a URL"; "onboarding.manual_setup.description" = "The URL of your Home Assistant server. Make sure it includes the protocol and port."; @@ -478,8 +484,9 @@ Tags will work on any device with Home Assistant installed which has hardware su "onboarding.welcome.description" = "This app connects to your Home Assistant server and allows integrating data about you and your phone.\ \ Home Assistant is free and open source home automation software with a focus on local control and privacy."; -"onboarding.welcome.learn_more" = "Learn more"; -"onboarding.welcome.title" = "Welcome to Home Assistant %@!"; +"onboarding.welcome.get_started" = "Get started with Home Assistant"; +"onboarding.servers.search.message" = "Looking for servers nearby..."; +"onboarding.servers.docs.read" = "Read documentation"; "open_label" = "Open"; "permission.screen.bluetooth.secondary_button" = "Skip"; "permission.screen.bluetooth.subtitle" = "The Home Assistant app can find devices using Bluetooth of this device. Allow Bluetooth access for the Home Assistant app."; @@ -1179,4 +1186,13 @@ Home Assistant is free and open source home automation software with a focus on "widgets.sensors.description" = "Display state of sensors"; "widgets.sensors.not_configured" = "No Sensors Configured"; "widgets.sensors.title" = "Sensors"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; +"onboarding.permission.location.description" = "To identify if you are at home and connect locally to Home Assistant, Apple requires that we ask for your location permission."; +"onboarding.permission.location.privacy_note" = "Your location won't be shared with your local Home Assistant server unless you select 'Allow & Share with Home Assistant'. You can choose to share later in companion app settings > sensors."; +"onboarding.permission.location.buttons.allow_and_share" = "Allow & Share with Home Assistant"; +"onboarding.permission.location.buttons.allow_for_app" = "Allow for App use only"; +"onboarding.permission.location.buttons.deny" = "Deny"; +"onboarding.permission.location.deny.alert.title" = "Are you sure?"; +"onboarding.permission.location.deny.alert.message" = "Without location permission future versions of the App may deny access to your local Home Assistant server due to privacy concerns. If you are sure, please continue and tap 'Deny' on the next popup as well. By doing that we recommend you use your internal URL as external, since it is the only URL the app will try to access."; +"post_onboarding.permission.notification.title" = "Do you want to receive notifications?"; +"post_onboarding.permission.notification.message" = "Notifications can be useful in your automations. Tap the icon to allow or deny."; diff --git a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift index 634053bb3..f9d262430 100644 --- a/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift +++ b/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift @@ -292,7 +292,7 @@ struct WatchConfigurationView: View { .font(.footnote) .padding(Spaces.one) .background(.gray.opacity(0.3)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.one)) } } diff --git a/Sources/App/Settings/SettingsViewController.swift b/Sources/App/Settings/SettingsViewController.swift index 3b4e778b6..c5543e4dc 100644 --- a/Sources/App/Settings/SettingsViewController.swift +++ b/Sources/App/Settings/SettingsViewController.swift @@ -26,7 +26,7 @@ class SettingsViewController: HAFormViewController { super.init() } - class func servers() -> (Section, deallocate: () -> Void) { + class func servers(controller: UIViewController) -> (Section, deallocate: () -> Void) { class Observer: ServerObserver { let updateRows: () -> Void init(updateRows: @escaping () -> Void) { @@ -56,11 +56,13 @@ class SettingsViewController: HAFormViewController { rows.append(HomeAssistantAccountRow { $0.value = .add - $0.presentationMode = .show(controllerProvider: .callback(builder: { () -> UIViewController in - OnboardingNavigationViewController(onboardingStyle: .secondary) - }), onDismiss: nil) $0.onCellSelection { _, row in row.deselect(animated: true) + controller.present( + OnboardingNavigationView.controller(onboardingStyle: .secondary), + animated: true, + completion: nil + ) } }) @@ -103,7 +105,7 @@ class SettingsViewController: HAFormViewController { } if contentSections.contains(.servers) { - let (section, deallocate) = Self.servers() + let (section, deallocate) = Self.servers(controller: self) form +++ section after(life: self).done(deallocate) } diff --git a/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift b/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift index 104f44046..49ddc103d 100644 --- a/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift +++ b/Sources/App/Settings/Widgets/Builder/WidgetCreationView.swift @@ -77,7 +77,7 @@ struct WidgetCreationView: View { } .frame(width: widthForPreview(), height: heightForPreview()) .background(Color.asset(Asset.Colors.primaryBackground)) - .clipShape(RoundedRectangle(cornerRadius: 18)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.twoAndMicro)) .shadow(color: .black.opacity(0.1), radius: 2) .padding(.vertical) Spacer() diff --git a/Sources/App/Settings/Widgets/TileCard.swift b/Sources/App/Settings/Widgets/TileCard.swift index 531b56964..ae40a7ec8 100644 --- a/Sources/App/Settings/Widgets/TileCard.swift +++ b/Sources/App/Settings/Widgets/TileCard.swift @@ -32,9 +32,9 @@ struct TileCard: View { .frame(maxWidth: .infinity) .frame(height: 55) .background(Color.asset(Asset.Colors.tileBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndMicro)) .overlay { - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndMicro) .stroke(Color.asset(Asset.Colors.tileBorder), lineWidth: 1) } } @@ -55,7 +55,7 @@ struct TileCard: View { } .frame(maxWidth: .infinity, alignment: .center) .background(Color.asset(Asset.Colors.primaryBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.two)) .shadow(color: .black.opacity(0.2), radius: 10) .padding() } diff --git a/Sources/App/Utilities/Permissions.swift b/Sources/App/Utilities/Permissions.swift index 00c2c4113..7c729bb44 100644 --- a/Sources/App/Utilities/Permissions.swift +++ b/Sources/App/Utilities/Permissions.swift @@ -145,38 +145,44 @@ public enum PermissionType { } } - var enableBulletPoints: [(MaterialDesignIcons, String)] { + struct BulletPoint { + var id = UUID().uuidString + let icon: MaterialDesignIcons + let text: String + } + + var enableBulletPoints: [BulletPoint] { switch self { case .location: return [ - (.homeAutomationIcon, L10n.Onboarding.Permissions.Location.Bullet.automations), - (.mapOutlineIcon, L10n.Onboarding.Permissions.Location.Bullet.history), - (.wifiIcon, L10n.Onboarding.Permissions.Location.Bullet.wifi), + BulletPoint(icon: .homeAutomationIcon, text: L10n.Onboarding.Permissions.Location.Bullet.automations), + BulletPoint(icon: .mapOutlineIcon, text: L10n.Onboarding.Permissions.Location.Bullet.history), + BulletPoint(icon: .wifiIcon, text: L10n.Onboarding.Permissions.Location.Bullet.wifi), ] case .motion: return [ - (.walkIcon, L10n.Onboarding.Permissions.Motion.Bullet.steps), - (.mapMarkerDistanceIcon, L10n.Onboarding.Permissions.Motion.Bullet.distance), - (.bikeIcon, L10n.Onboarding.Permissions.Motion.Bullet.activity), + BulletPoint(icon: .walkIcon, text: L10n.Onboarding.Permissions.Motion.Bullet.steps), + BulletPoint(icon: .mapMarkerDistanceIcon, text: L10n.Onboarding.Permissions.Motion.Bullet.distance), + BulletPoint(icon: .bikeIcon, text: L10n.Onboarding.Permissions.Motion.Bullet.activity), ] case .notification: return [ - (.alertDecagramIcon, L10n.Onboarding.Permissions.Notification.Bullet.alert), - (.textIcon, L10n.Onboarding.Permissions.Notification.Bullet.commands), - (.bellBadgeOutlineIcon, L10n.Onboarding.Permissions.Notification.Bullet.badge), + BulletPoint(icon: .alertDecagramIcon, text: L10n.Onboarding.Permissions.Notification.Bullet.alert), + BulletPoint(icon: .textIcon, text: L10n.Onboarding.Permissions.Notification.Bullet.commands), + BulletPoint(icon: .bellBadgeOutlineIcon, text: L10n.Onboarding.Permissions.Notification.Bullet.badge), ] case .focus: return [ - (.homeAutomationIcon, L10n.Onboarding.Permissions.Focus.Bullet.automations), - (.cancelIcon, L10n.Onboarding.Permissions.Focus.Bullet.instant), + BulletPoint(icon: .homeAutomationIcon, text: L10n.Onboarding.Permissions.Focus.Bullet.automations), + BulletPoint(icon: .cancelIcon, text: L10n.Onboarding.Permissions.Focus.Bullet.instant), ] } } var status: PermissionStatus { - let locationManager = CLLocationManager() switch self { case .location: + let locationManager = CLLocationManager() return locationManager.authorizationStatus.genericStatus case .motion: return CMMotionActivityManager.authorizationStatus().genericStatus diff --git a/Sources/App/WebView/ConnectionErrorDetailsView.swift b/Sources/App/WebView/ConnectionErrorDetailsView.swift index 897b65e44..5040f58cf 100644 --- a/Sources/App/WebView/ConnectionErrorDetailsView.swift +++ b/Sources/App/WebView/ConnectionErrorDetailsView.swift @@ -4,9 +4,21 @@ import SwiftUI struct ConnectionErrorDetailsView: View { @Environment(\.dismiss) private var dismiss + @State private var showExportLogsShareSheet: Bool = false + private let feedbackGenerator = UINotificationFeedbackGenerator() - let server: Server + let server: Server? let error: Error + let showSettingsEntry: Bool + let expandMoreDetails: Bool + + init(server: Server?, error: Error, showSettingsEntry: Bool = true, expandMoreDetails: Bool = false) { + self.server = server + self.error = error + self.showSettingsEntry = showSettingsEntry + self.expandMoreDetails = expandMoreDetails + } + var body: some View { NavigationView { ScrollView { @@ -31,10 +43,10 @@ struct ConnectionErrorDetailsView: View { .frame(maxWidth: 600) .padding() .background(Color(uiColor: .secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) } - if server.info.connection.canUseCloud, + if let server, server.info.connection.canUseCloud, let cloudText = try? AttributedString( markdown: L10n.Connection.Error.FailedConnect.Cloud .title @@ -48,9 +60,11 @@ struct ConnectionErrorDetailsView: View { } } } - openSettingsButton - CollapsibleView { - Text(L10n.ConnectionError.AdvancedSection.title) + if showSettingsEntry { + openSettingsButton + } + CollapsibleView(startExpanded: expandMoreDetails) { + Text(L10n.ConnectionError.MoreDetailsSection.title) .font(.body.bold()) } expandedContent: { advancedContent @@ -58,7 +72,7 @@ struct ConnectionErrorDetailsView: View { .frame(maxWidth: 600) .padding() .background(Color(uiColor: .secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) .padding(.top) Rectangle() @@ -67,6 +81,7 @@ struct ConnectionErrorDetailsView: View { .frame(height: 1) .padding(Spaces.three) copyToClipboardButton + exportLogsButton documentationLink discordLink githubLink @@ -77,14 +92,16 @@ struct ConnectionErrorDetailsView: View { .ignoresSafeArea(edges: .top) .toolbar { ToolbarItem(placement: .topBarLeading) { - Button(action: { - openSettings() - }, label: { - Image(uiImage: MaterialDesignIcons.cogIcon.image( - ofSize: .init(width: 28, height: 28), - color: .white - )) - }) + if showSettingsEntry { + Button(action: { + openSettings() + }, label: { + Image(uiImage: MaterialDesignIcons.cogIcon.image( + ofSize: .init(width: 28, height: 28), + color: .white + )) + }) + } } ToolbarItem(placement: .topBarTrailing) { CloseButton(tint: .white) { @@ -92,6 +109,9 @@ struct ConnectionErrorDetailsView: View { } } } + .sheet(isPresented: $showExportLogsShareSheet, content: { + ShareActivityView(activityItems: [Current.Log.archiveURL()]) + }) } .navigationViewStyle(.stack) } @@ -169,6 +189,21 @@ struct ConnectionErrorDetailsView: View { } } + private var exportLogsButton: some View { + ActionLinkButton( + icon: Image(systemSymbol: .squareAndArrowUp), + title: Current.Log.exportTitle, + tint: .init(uiColor: Asset.Colors.haPrimary.color) + ) { + if Current.isCatalyst, let logsURL = Current.Log.archiveURL() { + UIApplication.shared.open(logsURL) + } else { + showExportLogsShareSheet = true + feedbackGenerator.notificationOccurred(.success) + } + } + } + private var openSettingsButton: some View { ActionLinkButton( icon: Image(uiImage: MaterialDesignIcons.cogIcon.image( diff --git a/Sources/App/WebView/DownloadManager/DownloadManagerView.swift b/Sources/App/WebView/DownloadManager/DownloadManagerView.swift index 39a29fa2d..c5400e9c1 100644 --- a/Sources/App/WebView/DownloadManager/DownloadManagerView.swift +++ b/Sources/App/WebView/DownloadManager/DownloadManagerView.swift @@ -90,7 +90,7 @@ struct DownloadManagerView: View { .padding() .foregroundStyle(.white) .background(Color.asset(Asset.Colors.haPrimary)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) .padding() .onAppear(perform: { shareWrapper = .init(url: url) @@ -111,7 +111,7 @@ struct DownloadManagerView: View { .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(.gray.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) .padding() } @@ -121,7 +121,7 @@ struct DownloadManagerView: View { .multilineTextAlignment(.leading) .padding() .background(.red.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) .padding() } } diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index ee13c432a..bd26c831d 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -128,6 +128,8 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg if #available(iOS 16.4, *) { webView.isInspectable = true } + + postOnboardingNotificationPermission() } private func observeConnectionNotifications() { @@ -1053,7 +1055,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg } .padding() .background(Color.asset(Asset.Colors.haPrimary).opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) .padding(Spaces.one) DebugView() .toolbar { @@ -1358,3 +1360,55 @@ extension WebViewController { hud.hide(animated: true, afterDelay: 1.0) } } + +// MARK: - Post onboarding + +extension WebViewController { + private func postOnboardingNotificationPermission() { + // 3 seconds feels a good timin to show this notification after the user has onboarded + let delayedSeconds: CGFloat = 3 + DispatchQueue.main.asyncAfter(deadline: .now() + delayedSeconds) { [weak self] in + Task { + let notificationCenter = Current.userNotificationCenter + let settings = await notificationCenter.notificationSettings() + if ![.authorized, .denied].contains(settings.authorizationStatus) { + self?.showNotificationPermissionRequest() + } + } + } + } + + private func showNotificationPermissionRequest() { + let view = MessageView.viewFromNib(layout: .cardView) + var config = SwiftMessages.Config() + config.duration = .forever + config.presentationStyle = .top + config.dimMode = .gray(interactive: true) + view.configureContent( + title: L10n.PostOnboarding.Permission.Notification.title, + body: L10n.PostOnboarding.Permission.Notification.message, + iconImage: nil, + iconText: nil, + buttonImage: MaterialDesignIcons.arrowRightBoldCircleIcon.image( + ofSize: .init(width: 35, height: 35), + color: Asset.Colors.haPrimary.color + ), + buttonTitle: nil, + buttonTapHandler: { _ in + SwiftMessages.hide() + UNUserNotificationCenter.current().requestAuthorization(options: .defaultOptions) { _, error in + if let error { + Current.Log.error("Error when requesting notifications permissions: \(error)") + } + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + ) + view.titleLabel?.numberOfLines = 0 + view.bodyLabel?.numberOfLines = 0 + + SwiftMessages.show(config: config, view: view) + } +} diff --git a/Sources/App/WebView/WebViewWindowController.swift b/Sources/App/WebView/WebViewWindowController.swift index ca3cb8a8f..f5f1aafe7 100644 --- a/Sources/App/WebView/WebViewWindowController.swift +++ b/Sources/App/WebView/WebViewWindowController.swift @@ -2,6 +2,7 @@ import Foundation import MBProgressHUD import PromiseKit import Shared +import SwiftUI import UIKit final class WebViewWindowController { @@ -60,22 +61,16 @@ final class WebViewWindowController { } func setup() { - if let style = OnboardingNavigationViewController.requiredOnboardingStyle { - Current.Log.info("showing onboarding \(style)") - updateRootViewController(to: OnboardingNavigationViewController(onboardingStyle: style)) + if let style = OnboardingNavigation.requiredOnboardingStyle { + Current.Log.info("Showing onboarding \(style)") + updateRootViewController(to: OnboardingNavigationView.controller(onboardingStyle: style)) } else { - if let rootController = window.rootViewController, !rootController.children.isEmpty { - Current.Log.info("[iOS 12] state restoration loaded controller, not creating a new one") - // not changing anything, but handle the promises - updateRootViewController(to: rootController) + if let webViewController = makeWebViewIfNotInCache(restorationType: .init(restorationActivity)) { + updateRootViewController(to: webViewNavigationController(rootViewController: webViewController)) } else { - if let webViewController = makeWebViewIfNotInCache(restorationType: .init(restorationActivity)) { - updateRootViewController(to: webViewNavigationController(rootViewController: webViewController)) - } else { - updateRootViewController(to: OnboardingNavigationViewController(onboardingStyle: .initial)) - } - restorationActivity = nil + updateRootViewController(to: OnboardingNavigationView.controller(onboardingStyle: .initial)) } + restorationActivity = nil } } @@ -414,7 +409,7 @@ extension WebViewWindowController: OnboardingStateObserver { func onboardingStateDidChange(to state: OnboardingState) { switch state { case let .needed(type): - guard !(window.rootViewController is OnboardingNavigationViewController) else { + if window.rootViewController as? UIHostingController != nil { return } @@ -427,7 +422,7 @@ extension WebViewWindowController: OnboardingStateObserver { switch type { case .error, .logout: if Current.servers.all.isEmpty { - let controller = OnboardingNavigationViewController(onboardingStyle: .initial) + let controller = OnboardingNavigationView.controller(onboardingStyle: .initial) updateRootViewController(to: controller) if type.shouldShowError { @@ -462,7 +457,7 @@ extension WebViewWindowController: OnboardingStateObserver { shouldLoadImmediately: true ) case .complete: - if window.rootViewController is OnboardingNavigationViewController { + if window.rootViewController as? UIHostingController != nil { let controller: WebViewController? if let preload = onboardingPreloadWebViewController { diff --git a/Sources/Extensions/Watch/Home/WatchHomeView.swift b/Sources/Extensions/Watch/Home/WatchHomeView.swift index 71f023024..a46d5e444 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeView.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeView.swift @@ -88,7 +88,10 @@ struct WatchHomeView: View { if viewModel.showError { Text(viewModel.errorMessage) .font(.footnote) - .listRowBackground(Color.red.opacity(0.5).clipShape(RoundedRectangle(cornerRadius: 12))) + .listRowBackground( + Color.red.opacity(0.5) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) + ) } if viewModel.watchConfig.items.isEmpty { Text(verbatim: L10n.Watch.Labels.noConfig) diff --git a/Sources/Extensions/Widgets/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Sources/Extensions/Widgets/Resources/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index ae0e01f5c..000000000 --- a/Sources/Extensions/Widgets/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.960", - "green" : "0.659", - "red" : "0.010" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.960", - "green" : "0.659", - "red" : "0.010" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Sources/App/Improv/ImprovDiscoverView.swift b/Sources/Improv/ImprovDiscoverView.swift similarity index 100% rename from Sources/App/Improv/ImprovDiscoverView.swift rename to Sources/Improv/ImprovDiscoverView.swift diff --git a/Sources/App/Improv/Views/ImprovFailureView.swift b/Sources/Improv/Views/ImprovFailureView.swift similarity index 92% rename from Sources/App/Improv/Views/ImprovFailureView.swift rename to Sources/Improv/Views/ImprovFailureView.swift index 945cdf6d7..dd1349f8b 100644 --- a/Sources/App/Improv/Views/ImprovFailureView.swift +++ b/Sources/Improv/Views/ImprovFailureView.swift @@ -25,7 +25,7 @@ struct ImprovFailureView: View { } .frame(maxWidth: .infinity) .background(Color(uiColor: .systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.two)) } } diff --git a/Sources/App/Improv/Views/ImprovSuccessView.swift b/Sources/Improv/Views/ImprovSuccessView.swift similarity index 100% rename from Sources/App/Improv/Views/ImprovSuccessView.swift rename to Sources/Improv/Views/ImprovSuccessView.swift diff --git a/Sources/Shared/API/Responses/DiscoveredHomeAssistant.swift b/Sources/Shared/API/Responses/DiscoveredHomeAssistant.swift index 1fe7f8bec..b6058dcba 100644 --- a/Sources/Shared/API/Responses/DiscoveredHomeAssistant.swift +++ b/Sources/Shared/API/Responses/DiscoveredHomeAssistant.swift @@ -3,7 +3,7 @@ import ObjectMapper import PromiseKit import Version -public struct DiscoveredHomeAssistant: ImmutableMappable { +public struct DiscoveredHomeAssistant: ImmutableMappable, Equatable { public var uuid: String? public var version: Version public var internalOrExternalURL: URL @@ -14,7 +14,7 @@ public struct DiscoveredHomeAssistant: ImmutableMappable { public init(manualURL: URL, name: String = "Home") { self.version = Version(major: 2022, minor: 4) - self.uuid = nil + self.uuid = "autogenerated-\(UUID().uuidString)" self.internalOrExternalURL = manualURL self.internalURL = nil self.externalURL = manualURL diff --git a/Sources/Shared/API/Webhook/Sensors/SensorProvider.swift b/Sources/Shared/API/Webhook/SensorProvider.swift similarity index 100% rename from Sources/Shared/API/Webhook/Sensors/SensorProvider.swift rename to Sources/Shared/API/Webhook/SensorProvider.swift diff --git a/Sources/Shared/API/Webhook/Sensors/ActiveSensor.swift b/Sources/Shared/API/Webhook/Sensors/ActiveSensor.swift index 9efed5ec6..bdeba6244 100644 --- a/Sources/Shared/API/Webhook/Sensors/ActiveSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/ActiveSensor.swift @@ -35,7 +35,7 @@ final class ActiveSensor: SensorProvider { let sensor = WebhookSensor( name: "Active", - uniqueID: "active", + uniqueID: WebhookSensorId.active.rawValue, icon: isActive ? "mdi:monitor" : "mdi:monitor-off", state: isActive ) diff --git a/Sources/Shared/API/Webhook/Sensors/ActivitySensor.swift b/Sources/Shared/API/Webhook/Sensors/ActivitySensor.swift index 7f9cc8d2a..9ff09f29e 100644 --- a/Sources/Shared/API/Webhook/Sensors/ActivitySensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/ActivitySensor.swift @@ -18,7 +18,7 @@ public class ActivitySensor: SensorProvider { firstly { Self.latestMotionActivity() }.map { activity in - with(WebhookSensor(name: "Activity", uniqueID: "activity")) { + with(WebhookSensor(name: "Activity", uniqueID: WebhookSensorId.activity.rawValue)) { $0.State = activity.activityTypes.first $0.Attributes = [ "Confidence": activity.confidence.description, diff --git a/Sources/Shared/API/Webhook/Sensors/AppVersionSensor.swift b/Sources/Shared/API/Webhook/Sensors/AppVersionSensor.swift index da0f9279a..5ff66a855 100644 --- a/Sources/Shared/API/Webhook/Sensors/AppVersionSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/AppVersionSensor.swift @@ -10,7 +10,7 @@ final class AppVersionSensor: SensorProvider { func sensors() -> Promise<[WebhookSensor]> { let sensor = WebhookSensor( name: "App Version", - uniqueID: "app-version", + uniqueID: WebhookSensorId.appVersion.rawValue, icon: nil, state: AppConstants.version, entityCategory: "diagnostic" diff --git a/Sources/Shared/API/Webhook/Sensors/AudioOutputSensor.swift b/Sources/Shared/API/Webhook/Sensors/AudioOutputSensor.swift index f445519af..02ba69ee5 100644 --- a/Sources/Shared/API/Webhook/Sensors/AudioOutputSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/AudioOutputSensor.swift @@ -29,7 +29,7 @@ final class AudioOutputSensor: SensorProvider { let audioOutput = getAudioOutput().compactMap(\.type).joined(separator: ", ") sensors.append(.init( name: "Audio Output", - uniqueID: "iphone-audio-output", + uniqueID: WebhookSensorId.iPhoneAudioOutput.rawValue, icon: "mdi:volume-high", state: audioOutput )) diff --git a/Sources/Shared/API/Webhook/Sensors/ConnectivitySensor.swift b/Sources/Shared/API/Webhook/Sensors/ConnectivitySensor.swift index 5f68423a2..ad65d7c75 100644 --- a/Sources/Shared/API/Webhook/Sensors/ConnectivitySensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/ConnectivitySensor.swift @@ -73,7 +73,7 @@ public class ConnectivitySensor: SensorProvider { } return .value([ - with(WebhookSensor(name: "SSID", uniqueID: "connectivity_ssid")) { sensor in + with(WebhookSensor(name: "SSID", uniqueID: WebhookSensorId.connectivitySSID.rawValue)) { sensor in if let ssid = Current.connectivity.currentWiFiSSID() { sensor.State = ssid sensor.Icon = "mdi:wifi" @@ -82,7 +82,7 @@ public class ConnectivitySensor: SensorProvider { sensor.Icon = "mdi:wifi-off" } }, - with(WebhookSensor(name: "BSSID", uniqueID: "connectivity_bssid")) { sensor in + with(WebhookSensor(name: "BSSID", uniqueID: WebhookSensorId.connectivityBSID.rawValue)) { sensor in if let bssid = Current.connectivity.currentWiFiBSSID() { sensor.State = bssid sensor.Icon = "mdi:wifi-star" @@ -102,7 +102,10 @@ public class ConnectivitySensor: SensorProvider { let simple = Current.connectivity.simpleNetworkType() return .value([ - with(WebhookSensor(name: "Connection Type", uniqueID: "connectivity_connection_type")) { sensor in + with(WebhookSensor( + name: "Connection Type", + uniqueID: WebhookSensorId.connectivityConnectionType.rawValue + )) { sensor in sensor.State = simple.description sensor.Icon = simple.icon diff --git a/Sources/Shared/API/Webhook/Sensors/DisplaySensor.swift b/Sources/Shared/API/Webhook/Sensors/DisplaySensor.swift index 12c3b902d..be79f8e8b 100644 --- a/Sources/Shared/API/Webhook/Sensors/DisplaySensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/DisplaySensor.swift @@ -46,7 +46,7 @@ final class DisplaySensor: SensorProvider { sensors.append(with(WebhookSensor( name: "Displays", - uniqueID: "displays_count", + uniqueID: WebhookSensorId.displaysCount.rawValue, icon: "mdi:monitor-multiple", state: screens.count )) { @@ -58,14 +58,14 @@ final class DisplaySensor: SensorProvider { sensors.append(WebhookSensor( name: "Primary Display Name", - uniqueID: "primary_display_name", + uniqueID: WebhookSensorId.primaryDisplayName.rawValue, icon: "mdi:monitor-star", state: screens.first.map(\.name) ?? "None" )) sensors.append(WebhookSensor( name: "Primary Display ID", - uniqueID: "primary_display_id", + uniqueID: WebhookSensorId.primaryDisplayId.rawValue, icon: "mdi:monitor-star", state: screens.first.map(\.identifier) ?? "None" )) diff --git a/Sources/Shared/API/Webhook/Sensors/FrontmostAppSensor.swift b/Sources/Shared/API/Webhook/Sensors/FrontmostAppSensor.swift index 01c847605..497f8c7b0 100644 --- a/Sources/Shared/API/Webhook/Sensors/FrontmostAppSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/FrontmostAppSensor.swift @@ -43,7 +43,7 @@ final class FrontmostAppSensor: SensorProvider { sensors.append(with(WebhookSensor( name: "Frontmost App", - uniqueID: "frontmost_app", + uniqueID: WebhookSensorId.frontmostApp.rawValue, icon: "mdi:traffic-light", state: frontmost?.localizedName ?? "None" )) { diff --git a/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift b/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift index 7a7c0a8f3..30566cf59 100644 --- a/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift @@ -34,7 +34,10 @@ public class GeocoderSensor: SensorProvider { guard case GeocoderError.noLocation = error, case .registration = request.reason else { throw error } return .value([]) }.map { [request] (placemarks: [CLPlacemark]) -> [WebhookSensor] in - let sensor = with(WebhookSensor(name: "Geocoded Location", uniqueID: "geocoded_location")) { + let sensor = with(WebhookSensor( + name: "Geocoded Location", + uniqueID: WebhookSensorId.geocodedLocation.rawValue + )) { $0.State = "Unknown" $0.Icon = "mdi:\(MaterialDesignIcons.mapIcon.name)" $0.Settings = [ diff --git a/Sources/Shared/API/Webhook/Sensors/InputOutputDeviceSensor.swift b/Sources/Shared/API/Webhook/Sensors/InputOutputDeviceSensor.swift index 671d343b2..a067e0d0e 100644 --- a/Sources/Shared/API/Webhook/Sensors/InputOutputDeviceSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/InputOutputDeviceSensor.swift @@ -143,21 +143,21 @@ public class InputOutputDeviceSensor: SensorProvider { return Self.sensors( name: "Camera", - uniqueID: "camera", + uniqueID: WebhookSensorId.camera.rawValue, iconOn: "mdi:camera", iconOff: "mdi:camera-off", all: cameras.map { $0.name ?? cameraFallback }, active: cameras.filter(\.isOn).map { $0.name ?? cameraFallback } ) + Self.sensors( name: "Audio Input", - uniqueID: "microphone", + uniqueID: WebhookSensorId.microphone.rawValue, iconOn: "mdi:microphone", iconOff: "mdi:microphone-off", all: audioInputs.map { $0.name ?? audioInputFallback }, active: audioInputs.filter(\.isOn).map { $0.name ?? audioInputFallback } ) + Self.sensors( name: "Audio Output", - uniqueID: "audio_output", + uniqueID: WebhookSensorId.audioOutput.rawValue, iconOn: "mdi:volume-high", iconOff: "mdi:volume-low", all: audioOutputs.map { $0.name ?? audioOutputFallback }, diff --git a/Sources/Shared/API/Webhook/Sensors/LastUpdateSensor.swift b/Sources/Shared/API/Webhook/Sensors/LastUpdateSensor.swift index bfecb97fa..16f41625e 100644 --- a/Sources/Shared/API/Webhook/Sensors/LastUpdateSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/LastUpdateSensor.swift @@ -21,7 +21,7 @@ public class LastUpdateSensor: SensorProvider { } return .value([ - with(WebhookSensor(name: "Last Update Trigger", uniqueID: "last_update_trigger")) { + with(WebhookSensor(name: "Last Update Trigger", uniqueID: WebhookSensorId.lastUpdateTrigger.rawValue)) { $0.Icon = icon $0.State = request.lastUpdateReason }, diff --git a/Sources/Shared/API/Webhook/Sensors/LocationPermissionSensor.swift b/Sources/Shared/API/Webhook/Sensors/LocationPermissionSensor.swift index 061e99cf1..247aa240a 100644 --- a/Sources/Shared/API/Webhook/Sensors/LocationPermissionSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/LocationPermissionSensor.swift @@ -9,7 +9,7 @@ public class LocationPermissionSensor: SensorProvider { } public func sensors() -> Promise<[WebhookSensor]> { - let sensor = WebhookSensor(name: "Location permission", uniqueID: "location-permission") + let sensor = WebhookSensor(name: "Location permission", uniqueID: WebhookSensorId.locationPermission.rawValue) sensor.State = CLLocationManager().authorizationStatus.description sensor.Icon = "mdi:\(MaterialDesignIcons.mapIcon.name)" return .value([sensor]) diff --git a/Sources/Shared/API/Webhook/Sensors/StorageSensor.swift b/Sources/Shared/API/Webhook/Sensors/StorageSensor.swift index d4359b3ec..45d9499c1 100644 --- a/Sources/Shared/API/Webhook/Sensors/StorageSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/StorageSensor.swift @@ -35,7 +35,7 @@ public class StorageSensor: SensorProvider { private static func sensor(for volumes: [URLResourceKey: Int64]) throws -> WebhookSensor { let sensor = WebhookSensor( name: "Storage", - uniqueID: "storage", + uniqueID: WebhookSensorId.storage.rawValue, icon: .databaseIcon, state: "Unknown" ) diff --git a/Sources/Shared/API/Webhook/Sensors/WatchBatterySensor.swift b/Sources/Shared/API/Webhook/Sensors/WatchBatterySensor.swift index 270a092a5..63ba43626 100644 --- a/Sources/Shared/API/Webhook/Sensors/WatchBatterySensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/WatchBatterySensor.swift @@ -38,7 +38,7 @@ final class WatchBatterySensor: SensorProvider { if let batteryLevel { sensors.append(WebhookSensor( name: "Watch Battery Level", - uniqueID: "watch-battery", + uniqueID: WebhookSensorId.watchBattery.rawValue, icon: icon, deviceClass: .battery, state: batteryLevel, @@ -49,7 +49,7 @@ final class WatchBatterySensor: SensorProvider { if let batteryState { sensors.append(WebhookSensor( name: "Watch Battery State", - uniqueID: "watch-battery-state", + uniqueID: WebhookSensorId.watchBatteryState.rawValue, icon: icon, state: batteryState.description )) diff --git a/Sources/Shared/API/Webhook/WebhookSensorId.swift b/Sources/Shared/API/Webhook/WebhookSensorId.swift new file mode 100644 index 000000000..55cc9aaa6 --- /dev/null +++ b/Sources/Shared/API/Webhook/WebhookSensorId.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum WebhookSensorId: String, CaseIterable { + case iPhoneAudioOutput = "iphone-audio-output" + case activity = "activity" + case connectivitySSID = "connectivity_ssid" + case connectivityBSID = "connectivity_bssid" + case connectivityConnectionType = "connectivity_connection_type" + case geocodedLocation = "geocoded_location" + case lastUpdateTrigger = "last_update_trigger" + case storage = "storage" + case camera = "camera" + case microphone = "microphone" + case audioOutput = "audio_output" + case active = "active" + case displaysCount = "displays_count" + case primaryDisplayName = "primary_display_name" + case primaryDisplayId = "primary_display_id" + case frontmostApp = "frontmost_app" + case watchBattery = "watch-battery" + case watchBatteryState = "watch-battery-state" + case appVersion = "app-version" + case locationPermission = "location-permission" +} diff --git a/Sources/Shared/Assets/Assets.swift b/Sources/Shared/Assets/Assets.swift index 6c4fed888..02502206c 100644 --- a/Sources/Shared/Assets/Assets.swift +++ b/Sources/Shared/Assets/Assets.swift @@ -34,6 +34,7 @@ public enum Asset { public static let casita = ImageAsset(name: "casita") public static let haCloudLogo = ImageAsset(name: "ha-cloud-logo") public static let improvLogo = ImageAsset(name: "improv-logo") + public static let logoHorizontalText = ImageAsset(name: "logo-horizontal-text") public static let statusItemIcon = ImageAsset(name: "statusItemIcon") public static let thread = ImageAsset(name: "thread") } diff --git a/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/Contents.json b/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/Contents.json new file mode 100644 index 000000000..c672f8657 --- /dev/null +++ b/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "logo-horizontal-text.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "logo-horizontal-text-dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/logo-horizontal-text-dark.png b/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/logo-horizontal-text-dark.png new file mode 100644 index 000000000..cef3ae43a Binary files /dev/null and b/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/logo-horizontal-text-dark.png differ diff --git a/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/logo-horizontal-text.png b/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/logo-horizontal-text.png new file mode 100644 index 000000000..22d50d775 Binary files /dev/null and b/Sources/Shared/Assets/SharedAssets.xcassets/logo-horizontal-text.imageset/logo-horizontal-text.png differ diff --git a/Sources/Shared/Common/Extensions/View+HA.swift b/Sources/Shared/Common/Extensions/View+HA.swift new file mode 100644 index 000000000..e39c2cde5 --- /dev/null +++ b/Sources/Shared/Common/Extensions/View+HA.swift @@ -0,0 +1,16 @@ +import Foundation +import SwiftUI + +public extension View { + func embeddedInHostingController() -> UIHostingController { + let provider = ViewControllerProvider() + let hostingAccessingView = environmentObject(provider) + let hostingController = UIHostingController(rootView: hostingAccessingView) + provider.viewController = hostingController + return hostingController + } +} + +public final class ViewControllerProvider: ObservableObject { + public fileprivate(set) weak var viewController: UIViewController? +} diff --git a/Sources/Shared/DesignSystem/Components/CollapsibleView.swift b/Sources/Shared/DesignSystem/Components/CollapsibleView.swift index 91058fbde..5c87197c3 100644 --- a/Sources/Shared/DesignSystem/Components/CollapsibleView.swift +++ b/Sources/Shared/DesignSystem/Components/CollapsibleView.swift @@ -5,12 +5,16 @@ public struct CollapsibleView: Vi @ViewBuilder public let collapsedContent: () -> CollapsedContent @ViewBuilder public let expandedContent: () -> ExpandedContent + private let startExpanded: Bool + public init( + startExpanded: Bool = false, @ViewBuilder collapsedContent: @escaping () -> CollapsedContent, @ViewBuilder expandedContent: @escaping () -> ExpandedContent ) { self.collapsedContent = collapsedContent self.expandedContent = expandedContent + self.startExpanded = startExpanded } public var body: some View { @@ -23,6 +27,9 @@ public struct CollapsibleView: Vi } .frame(maxWidth: .infinity) .animation(nil, value: expanded) + .onAppear { + expanded = startExpanded + } .onTapGesture { withAnimation(.easeInOut) { expanded.toggle() diff --git a/Sources/Shared/DesignSystem/Components/ExternalLinkButton.swift b/Sources/Shared/DesignSystem/Components/ExternalLinkButton.swift index 84407056a..6b1171168 100644 --- a/Sources/Shared/DesignSystem/Components/ExternalLinkButton.swift +++ b/Sources/Shared/DesignSystem/Components/ExternalLinkButton.swift @@ -41,7 +41,7 @@ public struct ExternalLinkButton: View { .frame(maxWidth: 600) .padding() .background(background) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) } } @@ -77,7 +77,7 @@ public struct ActionLinkButton: View { .frame(maxWidth: 600) .padding() .background(Color(uiColor: .secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndHalf)) } } diff --git a/Sources/Shared/DesignSystem/Components/PrivacyNoteView.swift b/Sources/Shared/DesignSystem/Components/PrivacyNoteView.swift new file mode 100644 index 000000000..46b42f260 --- /dev/null +++ b/Sources/Shared/DesignSystem/Components/PrivacyNoteView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +/// View used to display and highlight privacy related information +public struct PrivacyNoteView: View { + @State private var startPoint: UnitPoint = .topLeading + @State private var endPoint: UnitPoint = .bottomTrailing + @State private var timer: Timer? + @State private var background: AnyView = .init( + LinearGradient( + colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .overlay(content: { + ThickMaterialOverlay() + }) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndMicro)) + ) + + private let content: String + private let animating: Bool + + public init(content: String, animating: Bool = true) { + self.content = content + self.animating = animating + } + + public var body: some View { + VStack(spacing: Spaces.one) { + Text(L10n.privacyLabel) + .font(.caption.bold()) + .padding(.horizontal, Spaces.one) + .padding(.vertical, Spaces.half) + .background(.thickMaterial) + .foregroundStyle(.gray) + .clipShape(Capsule()) + .frame(maxWidth: .infinity, alignment: .leading) + Text(verbatim: content) + .frame(maxWidth: .infinity, alignment: .leading) + .font(.caption) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.gray) + } + .padding(Spaces.one) + .background(background) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndMicro)) + .shadow(color: Color(uiColor: .label).opacity(0.2), radius: 5) + .padding(.top) + .onAppear { + if animating { + rotareLinearBackgroundPointsForBackgroundAnimation() + startTimer() + } + } + .onDisappear { + stopTimer() + } + } + + private func startTimer() { + timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + rotareLinearBackgroundPointsForBackgroundAnimation() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func rotareLinearBackgroundPointsForBackgroundAnimation() { + startPoint = rotatePoint(startPoint) + endPoint = rotatePoint(endPoint) + withAnimation(.easeIn(duration: 2)) { + background = AnyView( + LinearGradient( + colors: [.purple, .blue], + startPoint: startPoint, + endPoint: endPoint + ) + .overlay(content: { + ThickMaterialOverlay() + }) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.oneAndMicro)) + ) + } + } + + private func rotatePoint(_ point: UnitPoint) -> UnitPoint { + switch point { + case .topLeading: + return .top + case .top: + return .topTrailing + case .topTrailing: + return .trailing + case .trailing: + return .bottomTrailing + case .bottomTrailing: + return .bottom + case .bottom: + return .bottomLeading + case .bottomLeading: + return .leading + case .leading: + return .topLeading + default: + return .topLeading + } + } +} + +struct ThickMaterialOverlay: View { + var body: some View { + VStack {} + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.thickMaterial) + } +} + +#Preview { + VStack { + PrivacyNoteView( + content: "This is a privacy note. It contains important information about your data and how it is used." + ) + .padding() + PrivacyNoteView( + content: "This is a privacy note. It contains important information about your data and how it is used.", + animating: false + ) + .padding() + } +} diff --git a/Sources/Shared/DesignSystem/Constants/CornerRadiusSizes.swift b/Sources/Shared/DesignSystem/Constants/CornerRadiusSizes.swift new file mode 100644 index 000000000..7fabe3f39 --- /dev/null +++ b/Sources/Shared/DesignSystem/Constants/CornerRadiusSizes.swift @@ -0,0 +1,18 @@ +import Foundation + +// 8-point system +public enum CornerRadiusSizes { + public static var half: CGFloat = 4 + public static var one: CGFloat = 8 + public static var oneAndHalf: CGFloat = 12 + public static var two: CGFloat = 16 + public static var three: CGFloat = 24 + public static var four: CGFloat = 32 + public static var five: CGFloat = 40 + public static var six: CGFloat = 48 + + // Out of system points + public static var micro: CGFloat = 2 + public static var oneAndMicro: CGFloat = 10 + public static var twoAndMicro: CGFloat = 18 +} diff --git a/Sources/Shared/DesignSystem/Constants/Sizes.swift b/Sources/Shared/DesignSystem/Constants/Sizes.swift new file mode 100644 index 000000000..882e3fd42 --- /dev/null +++ b/Sources/Shared/DesignSystem/Constants/Sizes.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum Sizes { + static var maxWidthForLargerScreens: CGFloat = 600 +} diff --git a/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift b/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift index 563c8d469..40470aa91 100644 --- a/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift +++ b/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift @@ -1,26 +1,95 @@ import Foundation import SwiftUI +enum HAButtonStylesConstants { + static var height: CGFloat = 55 + static var cornerRadius: CGFloat = 12 + static var disabledOpacity: CGFloat = 0.5 +} + public struct HAButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + public func makeBody(configuration: Configuration) -> some View { configuration.label .font(.callout.bold()) .foregroundColor(.white) .frame(maxWidth: .infinity) - .frame(height: 55) - .background(Color.asset(Asset.Colors.haPrimary)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(height: HAButtonStylesConstants.height) + .background(isEnabled ? Color.asset(Asset.Colors.haPrimary) : Color.gray) + .clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius)) + .opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity) + } +} + +public struct HANeutralButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.bold()) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: HAButtonStylesConstants.height) + .background(Color.gray) + .clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius)) + .opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity) + } +} + +public struct HANegativeButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.bold()) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: HAButtonStylesConstants.height) + .background(isEnabled ? .red : Color.gray) + .clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius)) + .opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity) } } public struct HASecondaryButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + public func makeBody(configuration: Configuration) -> some View { configuration.label .font(.callout.bold()) .foregroundColor(Color.asset(Asset.Colors.haPrimary)) .frame(maxWidth: .infinity) - .frame(height: 55) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(height: HAButtonStylesConstants.height) + .clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius)) + .opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity) + } +} + +public struct HASecondaryNegativeButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.bold()) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: HAButtonStylesConstants.height) + .clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius)) + .opacity(isEnabled ? 1 : HAButtonStylesConstants.disabledOpacity) + } +} + +public struct HAPillButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout.bold()) + .foregroundColor(.white) + .padding(.vertical, Spaces.one) + .padding(.horizontal, Spaces.oneAndHalf) + .background(Color.asset(Asset.Colors.haPrimary)) + .frame(alignment: .leading) + .clipShape(Capsule()) } } @@ -31,11 +100,14 @@ public struct HACriticalButtonStyle: ButtonStyle { .font(.callout.bold()) .foregroundColor(.black) .frame(maxWidth: .infinity) - .frame(height: 55) + .frame(height: HAButtonStylesConstants.height) .padding() .background(.red.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.red, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius)) + .overlay(RoundedRectangle(cornerRadius: HAButtonStylesConstants.cornerRadius).stroke( + Color.red, + lineWidth: 1 + )) } } @@ -54,12 +126,30 @@ public extension ButtonStyle where Self == HAButtonStyle { } } +public extension ButtonStyle where Self == HANegativeButtonStyle { + static var negativeButton: HANegativeButtonStyle { + HANegativeButtonStyle() + } +} + +public extension ButtonStyle where Self == HANeutralButtonStyle { + static var neutralButton: HANeutralButtonStyle { + HANeutralButtonStyle() + } +} + public extension ButtonStyle where Self == HASecondaryButtonStyle { static var secondaryButton: HASecondaryButtonStyle { HASecondaryButtonStyle() } } +public extension ButtonStyle where Self == HASecondaryNegativeButtonStyle { + static var secondaryNegativeButton: HASecondaryNegativeButtonStyle { + HASecondaryNegativeButtonStyle() + } +} + public extension ButtonStyle where Self == HALinkButtonStyle { static var linkButton: HALinkButtonStyle { HALinkButtonStyle() @@ -71,3 +161,9 @@ public extension ButtonStyle where Self == HACriticalButtonStyle { HACriticalButtonStyle() } } + +public extension ButtonStyle where Self == HAPillButtonStyle { + static var pillButton: HAPillButtonStyle { + HAPillButtonStyle() + } +} diff --git a/Sources/Shared/Environment/AppConstants.swift b/Sources/Shared/Environment/AppConstants.swift index 5bfb44337..c1f602bf5 100644 --- a/Sources/Shared/Environment/AppConstants.swift +++ b/Sources/Shared/Environment/AppConstants.swift @@ -5,6 +5,14 @@ import Version /// Contains shared constants public enum AppConstants { + public enum WebURLs { + public static var homeAssistant = URL(string: "https://www.home-assistant.io")! + public static var homeAssistantGetStarted = URL(string: "https://www.home-assistant.io/installation/")! + public static var companionAppDocs = URL(string: "https://companion.home-assistant.io")! + public static var companionAppDocsTroubleshooting = + URL(string: "https://companion.home-assistant.io/docs/troubleshooting/errors")! + } + /// Home Assistant Blue public static var tintColor: UIColor { #if os(iOS) diff --git a/Sources/Shared/Environment/ConnectivityWrapper.swift b/Sources/Shared/Environment/ConnectivityWrapper.swift index 45e4b43b9..e202c7ad1 100644 --- a/Sources/Shared/Environment/ConnectivityWrapper.swift +++ b/Sources/Shared/Environment/ConnectivityWrapper.swift @@ -107,7 +107,7 @@ public class ConnectivityWrapper { syncNetworkInformation() } - private func syncNetworkInformation() { + public func syncNetworkInformation(completion: (() -> Void)? = nil) { NEHotspotNetwork.fetchCurrent { hotspotNetwork in Current.Log .verbose( @@ -122,6 +122,7 @@ public class ConnectivityWrapper { } let bssid = hotspotNetwork?.bssid self.currentWiFiBSSID = { bssid } + completion?() } } } diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index a491300e2..45faa2856 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -6,6 +6,7 @@ import GRDB import HAKit import PromiseKit import RealmSwift +import UserNotifications import Version import XCGLogger @@ -432,4 +433,14 @@ public class AppEnvironment { public var bluetoothPermissionStatus: CBManagerAuthorization { CBCentralManager.authorization } + + public var userNotificationCenter: UNUserNotificationCenter { + UNUserNotificationCenter.current() + } + + #if !os(watchOS) + public var bonjour: () -> BonjourProtocol = { + Bonjour() + } + #endif } diff --git a/Sources/Shared/Environment/OnboardingStateObservation.swift b/Sources/Shared/Environment/OnboardingStateObservation.swift index 74c4013a2..d20bdb55e 100644 --- a/Sources/Shared/Environment/OnboardingStateObservation.swift +++ b/Sources/Shared/Environment/OnboardingStateObservation.swift @@ -1,7 +1,7 @@ import Foundation -public enum OnboardingState { - public enum NeededType { +public enum OnboardingState: Equatable { + public enum NeededType: Equatable { case logout case error case unauthenticated(_ serverId: String, _ code: Int) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index fe39dbcdb..514af3224 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -44,6 +44,8 @@ public enum L10n { public static var openLabel: String { return L10n.tr("Localizable", "open_label") } /// Preview Output public static var previewOutput: String { return L10n.tr("Localizable", "preview_output") } + /// Privacy + public static var privacyLabel: String { return L10n.tr("Localizable", "privacyLabel") } /// Reload public static var reloadLabel: String { return L10n.tr("Localizable", "reload_label") } /// Requires %@ or later. @@ -864,9 +866,9 @@ public enum L10n { } public enum ConnectionError { - public enum AdvancedSection { - /// Advanced - public static var title: String { return L10n.tr("Localizable", "connection_error.advanced_section.title") } + public enum MoreDetailsSection { + /// More details + public static var title: String { return L10n.tr("Localizable", "connection_error.more_details_section.title") } } public enum OpenSettings { /// Open settings @@ -1692,12 +1694,52 @@ public enum L10n { /// Could not create a URL public static var title: String { return L10n.tr("Localizable", "onboarding.manual_setup.couldnt_make_url.title") } } + public enum HelperSection { + /// Did you mean... + public static var title: String { return L10n.tr("Localizable", "onboarding.manual_setup.helper_section.title") } + } + public enum InputError { + /// Make sure you have entered a valid URL. + public static var message: String { return L10n.tr("Localizable", "onboarding.manual_setup.input_error.message") } + /// Invalid URL + public static var title: String { return L10n.tr("Localizable", "onboarding.manual_setup.input_error.title") } + } public enum NoScheme { /// Should we try connecting using http:// or https://? public static var message: String { return L10n.tr("Localizable", "onboarding.manual_setup.no_scheme.message") } /// URL entered without scheme public static var title: String { return L10n.tr("Localizable", "onboarding.manual_setup.no_scheme.title") } } + public enum TextField { + /// e.g. http://homeassistant.local:8123 + public static var placeholder: String { return L10n.tr("Localizable", "onboarding.manual_setup.text_field.placeholder") } + /// Your Home Assistant URL + public static var title: String { return L10n.tr("Localizable", "onboarding.manual_setup.text_field.title") } + } + } + public enum Permission { + public enum Location { + /// To identify if you are at home and connect locally to Home Assistant, Apple requires that we ask for your location permission. + public static var description: String { return L10n.tr("Localizable", "onboarding.permission.location.description") } + /// Your location won't be shared with your local Home Assistant server unless you select 'Allow & Share with Home Assistant'. You can choose to share later in companion app settings > sensors. + public static var privacyNote: String { return L10n.tr("Localizable", "onboarding.permission.location.privacy_note") } + public enum Buttons { + /// Allow & Share with Home Assistant + public static var allowAndShare: String { return L10n.tr("Localizable", "onboarding.permission.location.buttons.allow_and_share") } + /// Allow for App use only + public static var allowForApp: String { return L10n.tr("Localizable", "onboarding.permission.location.buttons.allow_for_app") } + /// Deny + public static var deny: String { return L10n.tr("Localizable", "onboarding.permission.location.buttons.deny") } + } + public enum Deny { + public enum Alert { + /// Without location permission future versions of the App may deny access to your local Home Assistant server due to privacy concerns. If you are sure, please continue and tap 'Deny' on the next popup as well. By doing that we recommend you use your internal URL as external, since it is the only URL the app will try to access. + public static var message: String { return L10n.tr("Localizable", "onboarding.permission.location.deny.alert.message") } + /// Are you sure? + public static var title: String { return L10n.tr("Localizable", "onboarding.permission.location.deny.alert.title") } + } + } + } } public enum Permissions { /// Allow @@ -1781,17 +1823,23 @@ public enum L10n { /// Scanning for Servers public static var title: String { return L10n.tr("Localizable", "onboarding.scanning.title") } } + public enum Servers { + public enum Docs { + /// Read documentation + public static var read: String { return L10n.tr("Localizable", "onboarding.servers.docs.read") } + } + public enum Search { + /// Looking for servers nearby... + public static var message: String { return L10n.tr("Localizable", "onboarding.servers.search.message") } + } + } public enum Welcome { /// This app connects to your Home Assistant server and allows integrating data about you and your phone. /// /// Home Assistant is free and open source home automation software with a focus on local control and privacy. public static var description: String { return L10n.tr("Localizable", "onboarding.welcome.description") } - /// Learn more - public static var learnMore: String { return L10n.tr("Localizable", "onboarding.welcome.learn_more") } - /// Welcome to Home Assistant %@! - public static func title(_ p1: Any) -> String { - return L10n.tr("Localizable", "onboarding.welcome.title", String(describing: p1)) - } + /// Get started with Home Assistant + public static var getStarted: String { return L10n.tr("Localizable", "onboarding.welcome.get_started") } } } @@ -1808,6 +1856,17 @@ public enum L10n { } } + public enum PostOnboarding { + public enum Permission { + public enum Notification { + /// Notifications can be useful in your automations. Tap the icon to allow or deny. + public static var message: String { return L10n.tr("Localizable", "post_onboarding.permission.notification.message") } + /// Do you want to receive notifications? + public static var title: String { return L10n.tr("Localizable", "post_onboarding.permission.notification.title") } + } + } + } + public enum Sensors { public enum Active { public enum Setting { diff --git a/Sources/App/Thread/CredentialsManagement/ThreadCredentialsManagementView.swift b/Sources/Thread/CredentialsManagement/ThreadCredentialsManagementView.swift similarity index 100% rename from Sources/App/Thread/CredentialsManagement/ThreadCredentialsManagementView.swift rename to Sources/Thread/CredentialsManagement/ThreadCredentialsManagementView.swift diff --git a/Sources/App/Thread/CredentialsManagement/ThreadCredentialsManagementViewModel.swift b/Sources/Thread/CredentialsManagement/ThreadCredentialsManagementViewModel.swift similarity index 100% rename from Sources/App/Thread/CredentialsManagement/ThreadCredentialsManagementViewModel.swift rename to Sources/Thread/CredentialsManagement/ThreadCredentialsManagementViewModel.swift diff --git a/Sources/App/Thread/CredentialsManagement/Views/ThreadCredentialDetailsView.swift b/Sources/Thread/CredentialsManagement/Views/ThreadCredentialDetailsView.swift similarity index 98% rename from Sources/App/Thread/CredentialsManagement/Views/ThreadCredentialDetailsView.swift rename to Sources/Thread/CredentialsManagement/Views/ThreadCredentialDetailsView.swift index 6234b6ae8..721cb9f54 100644 --- a/Sources/App/Thread/CredentialsManagement/Views/ThreadCredentialDetailsView.swift +++ b/Sources/Thread/CredentialsManagement/Views/ThreadCredentialDetailsView.swift @@ -65,7 +65,7 @@ struct ThreadCredentialDetailsView: View { .frame(height: 50) .background(actionButtonbackgroundColor) .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: CornerRadiusSizes.one)) .padding(.top) } diff --git a/Sources/App/Thread/CredentialsSharing/ThreadCredentialsSharing+build.swift b/Sources/Thread/CredentialsSharing/ThreadCredentialsSharing+build.swift similarity index 100% rename from Sources/App/Thread/CredentialsSharing/ThreadCredentialsSharing+build.swift rename to Sources/Thread/CredentialsSharing/ThreadCredentialsSharing+build.swift diff --git a/Sources/App/Thread/CredentialsSharing/ThreadCredentialsSharingView.swift b/Sources/Thread/CredentialsSharing/ThreadCredentialsSharingView.swift similarity index 100% rename from Sources/App/Thread/CredentialsSharing/ThreadCredentialsSharingView.swift rename to Sources/Thread/CredentialsSharing/ThreadCredentialsSharingView.swift diff --git a/Sources/App/Thread/CredentialsSharing/ThreadCredentialsSharingViewModelProtocol.swift b/Sources/Thread/CredentialsSharing/ThreadCredentialsSharingViewModelProtocol.swift similarity index 100% rename from Sources/App/Thread/CredentialsSharing/ThreadCredentialsSharingViewModelProtocol.swift rename to Sources/Thread/CredentialsSharing/ThreadCredentialsSharingViewModelProtocol.swift diff --git a/Sources/App/Thread/CredentialsSharing/ToAppleKeychain/ThreadCredentialsSharingToKeychainViewModel.swift b/Sources/Thread/CredentialsSharing/ToAppleKeychain/ThreadCredentialsSharingToKeychainViewModel.swift similarity index 100% rename from Sources/App/Thread/CredentialsSharing/ToAppleKeychain/ThreadCredentialsSharingToKeychainViewModel.swift rename to Sources/Thread/CredentialsSharing/ToAppleKeychain/ThreadCredentialsSharingToKeychainViewModel.swift diff --git a/Sources/App/Thread/CredentialsSharing/ToHomeAssistant/ThreadTransferCredentialToHAViewModel.swift b/Sources/Thread/CredentialsSharing/ToHomeAssistant/ThreadTransferCredentialToHAViewModel.swift similarity index 100% rename from Sources/App/Thread/CredentialsSharing/ToHomeAssistant/ThreadTransferCredentialToHAViewModel.swift rename to Sources/Thread/CredentialsSharing/ToHomeAssistant/ThreadTransferCredentialToHAViewModel.swift diff --git a/Sources/App/WatchCommunicatorService.swift b/Sources/Watch/WatchCommunicatorService.swift similarity index 100% rename from Sources/App/WatchCommunicatorService.swift rename to Sources/Watch/WatchCommunicatorService.swift diff --git a/Tests/App/DesignSystem/Components/CollapsibleViewTests.swift b/Tests/App/DesignSystem/Components/CollapsibleViewTests.swift new file mode 100644 index 000000000..99c35909c --- /dev/null +++ b/Tests/App/DesignSystem/Components/CollapsibleViewTests.swift @@ -0,0 +1,28 @@ +@testable import Shared +import SharedTesting +import SwiftUI +import Testing + +struct CollapsibleViewTests { + @MainActor + @Test func testCollapsibleViewCollapsed() async throws { + let view = CollapsibleView { + Text("This is a header") + } expandedContent: { + Text("This is a content") + } + + assertLightDarkSnapshots(of: view) + } + + @MainActor + @Test func testCollapsibleViewOpen() async throws { + let view = CollapsibleView(startExpanded: true) { + Text("This is a header") + } expandedContent: { + Text("This is a content") + } + + assertLightDarkSnapshots(of: view) + } +} diff --git a/Tests/App/DesignSystem/Components/ExternalLinkButtonTests.swift b/Tests/App/DesignSystem/Components/ExternalLinkButtonTests.swift new file mode 100644 index 000000000..5f05bf4ee --- /dev/null +++ b/Tests/App/DesignSystem/Components/ExternalLinkButtonTests.swift @@ -0,0 +1,19 @@ +@testable import Shared +import SharedTesting +import SwiftUI +import Testing + +struct ExternalLinkButtonTests { + @MainActor + @Test func testExternalLinkButton() async throws { + let view = ExternalLinkButton( + icon: Image(systemSymbol: .heart), + title: "This is a title", + url: URL(string: "https://google.com")!, + tint: .blue, + background: Color(uiColor: .secondarySystemFill) + ) + + assertLightDarkSnapshots(of: view) + } +} diff --git a/Tests/App/DesignSystem/Components/HAButtonStylesTests.swift b/Tests/App/DesignSystem/Components/HAButtonStylesTests.swift new file mode 100644 index 000000000..3ac0f67d3 --- /dev/null +++ b/Tests/App/DesignSystem/Components/HAButtonStylesTests.swift @@ -0,0 +1,38 @@ +@testable import Shared +import SharedTesting +import SwiftUI +import Testing +import WidgetKit + +struct HAButtonStylesTests { + @MainActor + @Test func testAppButtonStyles() async throws { + let listOfButtons = AnyView( + List { + VStack { + Button("primaryButton") {} + .buttonStyle(.primaryButton) + Button("secondaryButton") {} + .buttonStyle(.secondaryButton) + Button("negativeButton") {} + .buttonStyle(.negativeButton) + Button("neutralButton") {} + .buttonStyle(.neutralButton) + Button("secondaryNegativeButton") {} + .buttonStyle(.secondaryNegativeButton) + Button("linkButton") {} + .buttonStyle(.linkButton) + Button("criticalButton") {} + .buttonStyle(.criticalButton) + Button("pillButton") {} + .buttonStyle(.pillButton) + } + .padding(.horizontal) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + ) + + assertLightDarkSnapshots(of: listOfButtons) + } +} diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewCollapsed.dark.png b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewCollapsed.dark.png new file mode 100644 index 000000000..d77c3fcf4 Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewCollapsed.dark.png differ diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewCollapsed.light.png b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewCollapsed.light.png new file mode 100644 index 000000000..a4d38a1ea Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewCollapsed.light.png differ diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewOpen.dark.png b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewOpen.dark.png new file mode 100644 index 000000000..cb24261f4 Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewOpen.dark.png differ diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewOpen.light.png b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewOpen.light.png new file mode 100644 index 000000000..419386984 Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/CollapsibleViewTests/testCollapsibleViewOpen.light.png differ diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/ExternalLinkButtonTests/testExternalLinkButton.dark.png b/Tests/App/DesignSystem/Components/__Snapshots__/ExternalLinkButtonTests/testExternalLinkButton.dark.png new file mode 100644 index 000000000..99898435d Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/ExternalLinkButtonTests/testExternalLinkButton.dark.png differ diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/ExternalLinkButtonTests/testExternalLinkButton.light.png b/Tests/App/DesignSystem/Components/__Snapshots__/ExternalLinkButtonTests/testExternalLinkButton.light.png new file mode 100644 index 000000000..b95393fe6 Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/ExternalLinkButtonTests/testExternalLinkButton.light.png differ diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/HAButtonStylesTests/testAppButtonStyles.dark.png b/Tests/App/DesignSystem/Components/__Snapshots__/HAButtonStylesTests/testAppButtonStyles.dark.png new file mode 100644 index 000000000..1ad86a844 Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/HAButtonStylesTests/testAppButtonStyles.dark.png differ diff --git a/Tests/App/DesignSystem/Components/__Snapshots__/HAButtonStylesTests/testAppButtonStyles.light.png b/Tests/App/DesignSystem/Components/__Snapshots__/HAButtonStylesTests/testAppButtonStyles.light.png new file mode 100644 index 000000000..4249fc76d Binary files /dev/null and b/Tests/App/DesignSystem/Components/__Snapshots__/HAButtonStylesTests/testAppButtonStyles.light.png differ diff --git a/Tests/App/Onboarding/OnboardingNavigationTests.swift b/Tests/App/Onboarding/OnboardingNavigationTests.swift new file mode 100644 index 000000000..435e1474c --- /dev/null +++ b/Tests/App/Onboarding/OnboardingNavigationTests.swift @@ -0,0 +1,11 @@ +@testable import HomeAssistant +@testable import Shared +import Testing + +struct OnboardingNavigationTests { + @Test func testOnboardingNavigationWhenNoServers() async throws { + Current.servers = FakeServerManager(initial: 0) + let result = OnboardingNavigation.requiredOnboardingStyle + assert(result == .required(.full)) + } +} diff --git a/Tests/App/Onboarding/ServersList/OnboardingServersListViewModelTests.swift b/Tests/App/Onboarding/ServersList/OnboardingServersListViewModelTests.swift new file mode 100644 index 000000000..feadfcbfa --- /dev/null +++ b/Tests/App/Onboarding/ServersList/OnboardingServersListViewModelTests.swift @@ -0,0 +1,67 @@ +@testable import HomeAssistant +@testable import Shared +import Testing + +@Suite(.serialized) +struct OnboardingServersListViewModelTests { + @Test func testInitAddsSelfAsObserver() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + assert(sut.discoveredInstances.isEmpty) + assert((mockBonjour.observer as? OnboardingServersListViewModel) != nil) + } + + @Test func testStartDiscovery() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + + sut.startDiscovery() + assert(sut.discoveredInstances.isEmpty) + assert(mockBonjour.startCalled) + } + + @Test func testStopDiscovery() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + + sut.stopDiscovery() + assert(mockBonjour.stopCalled) + } + + @Test func testSelectInstance() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + let instance = DiscoveredHomeAssistant( + manualURL: URL(string: "http://192.168.0.1:8123")!, + name: "Home" + ) + let dummyController = await UIViewController() + sut.selectInstance(instance, controller: dummyController) + + assert(sut.currentlyInstanceLoading == instance) + } + + @Test func testResetFlow() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + + sut.resetFlow() + assert(sut.currentlyInstanceLoading == nil) + assert(sut.isLoading == false) + } +} diff --git a/Tests/App/OnboardingServersListViewModelTests.swift b/Tests/App/OnboardingServersListViewModelTests.swift new file mode 100644 index 000000000..feadfcbfa --- /dev/null +++ b/Tests/App/OnboardingServersListViewModelTests.swift @@ -0,0 +1,67 @@ +@testable import HomeAssistant +@testable import Shared +import Testing + +@Suite(.serialized) +struct OnboardingServersListViewModelTests { + @Test func testInitAddsSelfAsObserver() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + assert(sut.discoveredInstances.isEmpty) + assert((mockBonjour.observer as? OnboardingServersListViewModel) != nil) + } + + @Test func testStartDiscovery() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + + sut.startDiscovery() + assert(sut.discoveredInstances.isEmpty) + assert(mockBonjour.startCalled) + } + + @Test func testStopDiscovery() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + + sut.stopDiscovery() + assert(mockBonjour.stopCalled) + } + + @Test func testSelectInstance() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + let instance = DiscoveredHomeAssistant( + manualURL: URL(string: "http://192.168.0.1:8123")!, + name: "Home" + ) + let dummyController = await UIViewController() + sut.selectInstance(instance, controller: dummyController) + + assert(sut.currentlyInstanceLoading == instance) + } + + @Test func testResetFlow() async throws { + let mockBonjour = MockBonjour() + Current.bonjour = { + mockBonjour + } + let sut = OnboardingServersListViewModel() + + sut.resetFlow() + assert(sut.currentlyInstanceLoading == nil) + assert(sut.isLoading == false) + } +} diff --git a/Tests/App/Sizes/CornerRadiusSizesTests.swift b/Tests/App/Sizes/CornerRadiusSizesTests.swift new file mode 100644 index 000000000..7eebc3e38 --- /dev/null +++ b/Tests/App/Sizes/CornerRadiusSizesTests.swift @@ -0,0 +1,19 @@ +@testable import Shared +import SharedTesting +import Testing + +struct CornerRadiusSizesTests { + @Test func testCornerRadiusSizes() async throws { + assert(CornerRadiusSizes.micro == 2) + assert(CornerRadiusSizes.half == 4) + assert(CornerRadiusSizes.one == 8) + assert(CornerRadiusSizes.oneAndHalf == 12) + assert(CornerRadiusSizes.two == 16) + assert(CornerRadiusSizes.three == 24) + assert(CornerRadiusSizes.four == 32) + assert(CornerRadiusSizes.five == 40) + assert(CornerRadiusSizes.six == 48) + assert(CornerRadiusSizes.oneAndMicro == 10) + assert(CornerRadiusSizes.twoAndMicro == 18) + } +} diff --git a/Tests/App/Sizes/SpacesTests.swift b/Tests/App/Sizes/SpacesTests.swift new file mode 100644 index 000000000..054e7a1e8 --- /dev/null +++ b/Tests/App/Sizes/SpacesTests.swift @@ -0,0 +1,16 @@ +@testable import Shared +import SharedTesting +import Testing + +struct SpacesTests { + @Test func testSpacesSizes() async throws { + assert(Spaces.half == 4) + assert(Spaces.one == 8) + assert(Spaces.oneAndHalf == 12) + assert(Spaces.two == 16) + assert(Spaces.three == 24) + assert(Spaces.four == 32) + assert(Spaces.five == 40) + assert(Spaces.six == 48) + } +} diff --git a/Tests/App/Webhook/WebhookSensorIdTests.swift b/Tests/App/Webhook/WebhookSensorIdTests.swift new file mode 100644 index 000000000..78c9ffdc1 --- /dev/null +++ b/Tests/App/Webhook/WebhookSensorIdTests.swift @@ -0,0 +1,31 @@ +@testable import Shared +import Testing + +struct WebhookSensorIdTests { + @Test func testWebhookSensorIdRawValues() async throws { + assert(WebhookSensorId.iPhoneAudioOutput.rawValue == "iphone-audio-output") + assert(WebhookSensorId.activity.rawValue == "activity") + assert(WebhookSensorId.connectivitySSID.rawValue == "connectivity_ssid") + assert(WebhookSensorId.connectivityBSID.rawValue == "connectivity_bssid") + assert(WebhookSensorId.connectivityConnectionType.rawValue == "connectivity_connection_type") + assert(WebhookSensorId.geocodedLocation.rawValue == "geocoded_location") + assert(WebhookSensorId.lastUpdateTrigger.rawValue == "last_update_trigger") + assert(WebhookSensorId.storage.rawValue == "storage") + assert(WebhookSensorId.camera.rawValue == "camera") + assert(WebhookSensorId.microphone.rawValue == "microphone") + assert(WebhookSensorId.audioOutput.rawValue == "audio_output") + assert(WebhookSensorId.active.rawValue == "active") + assert(WebhookSensorId.displaysCount.rawValue == "displays_count") + assert(WebhookSensorId.primaryDisplayName.rawValue == "primary_display_name") + assert(WebhookSensorId.primaryDisplayId.rawValue == "primary_display_id") + assert(WebhookSensorId.frontmostApp.rawValue == "frontmost_app") + assert(WebhookSensorId.watchBattery.rawValue == "watch-battery") + assert(WebhookSensorId.watchBatteryState.rawValue == "watch-battery-state") + assert(WebhookSensorId.appVersion.rawValue == "app-version") + assert(WebhookSensorId.locationPermission.rawValue == "location-permission") + assert( + WebhookSensorId.allCases.count == 20, + "WebhookSensorId has different number of cases than defined in test, \(WebhookSensorId.allCases.count)" + ) + } +} diff --git a/Tests/Mocks/MockBonjour.swift b/Tests/Mocks/MockBonjour.swift new file mode 100644 index 000000000..ffe4120eb --- /dev/null +++ b/Tests/Mocks/MockBonjour.swift @@ -0,0 +1,17 @@ +import Foundation +import Shared + +final class MockBonjour: BonjourProtocol { + var observer: (any BonjourObserver)? + + var startCalled = false + var stopCalled = false + + func start() { + startCalled = true + } + + func stop() { + stopCalled = true + } +}