diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 562e51b1d..a10df9e2c 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1203,6 +1203,18 @@ 42FF5E972E22E5C100BDF5EF /* TodoListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42FF5E962E22E5C100BDF5EF /* TodoListItem.swift */; }; 42FF5E982E22E5C100BDF5EF /* TodoListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42FF5E962E22E5C100BDF5EF /* TodoListItem.swift */; }; 42FF5E992E22F84600BDF5EF /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 420B10032B1CF6D800D383D8 /* SharedAssets.xcassets */; }; + 2281600CB792D88059A71F4C /* KioskModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */; }; + D46379541BA5FD96D6E7D328 /* KioskSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402432B9CC897C6278B08A79 /* KioskSettings.swift */; }; + 51B609B13F56C4F17CEF2BE4 /* KioskConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */; }; + B6DDF8534A4176416CFAC79A /* KioskSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49767602CA2066683EC638F /* KioskSettingsView.swift */; }; + D8B4F2A61E9C73058AF2D49E /* KioskSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */; }; + E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; }; + 19F4C1F65664780F4E721699 /* KioskClockScreensaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D793A057CBFD32530A7193 /* KioskClockScreensaverView.swift */; }; + CB4D44CC6DBA5176155E157E /* KioskSecretExitGestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE1C6F8FA2181C936758465 /* KioskSecretExitGestureView.swift */; }; + 12FC58D695EB7AF41673476E /* WebViewController+Kiosk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A39058730A6B3E876D8848 /* WebViewController+Kiosk.swift */; }; + 87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; }; + C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */; }; + 072BACCC5B2509E4AF06BFED /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; }; 465BC1EE2D8DB87A00A30A60 /* LocationHistoryEntryListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465BC1ED2D8DB87A00A30A60 /* LocationHistoryEntryListItemView.swift */; }; 4697F4BF2D8A3F7500C5C467 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4697F4BE2D8A3F7500C5C467 /* XCTest.framework */; platformFilter = ios; }; 4697F4C12D8A416400C5C467 /* SharedTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46C62B882D8A3687002C0001 /* SharedTesting.framework */; }; @@ -1230,13 +1242,13 @@ 61495A70232316478717CF27 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF3A67FB1C2B548C6C7730C /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; }; - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 70BD8A8EA1ABC5DC1F0A0D6E /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */; }; 84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; AB3E076F146799C008ACB0EA /* Pods_iOS_Extensions_NotificationContent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; @@ -1492,7 +1504,7 @@ B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */; }; BD1044995DE13A04C0FA039A /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9C81015FD7A8FA8716E4F2 /* Pods_iOS_Extensions_Widgets.framework */; }; - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; }; + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; C8860D27D848451A887BC441 /* WatchFolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */; }; @@ -1535,7 +1547,7 @@ D0EEF335214EB77100D1D360 /* CLLocation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C17E20D1F64D00BD810B /* CLLocation+Extensions.swift */; }; D234820A53471DE7B485A445 /* Pods_Tests_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA48C686F844D08C426A8D74 /* Pods_Tests_App.framework */; }; D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; }; - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; FD3BC66C29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift */; }; @@ -2996,6 +3008,17 @@ 42FF5E902E22D7EC00BDF5EF /* WidgetTodoList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTodoList.swift; sourceTree = ""; }; 42FF5E932E22D83800BDF5EF /* WidgetTodoListProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTodoListProvider.swift; sourceTree = ""; }; 42FF5E962E22E5C100BDF5EF /* TodoListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListItem.swift; sourceTree = ""; }; + C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = ""; }; + 402432B9CC897C6278B08A79 /* KioskSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettings.swift; sourceTree = ""; }; + 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; + F49767602CA2066683EC638F /* KioskSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsView.swift; sourceTree = ""; }; + C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsViewModel.swift; sourceTree = ""; }; + 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskScreensaverViewController.swift; sourceTree = ""; }; + 38D793A057CBFD32530A7193 /* KioskClockScreensaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskClockScreensaverView.swift; sourceTree = ""; }; + FCE1C6F8FA2181C936758465 /* KioskSecretExitGestureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSecretExitGestureView.swift; sourceTree = ""; }; + 09A39058730A6B3E876D8848 /* WebViewController+Kiosk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebViewController+Kiosk.swift"; sourceTree = ""; }; + 14444A34DA125693568C7035 /* KioskSettingsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsTable.swift; sourceTree = ""; }; + EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettings.test.swift; sourceTree = ""; }; 442182FC55CEB695729D80CA /* Pods-watchOS-Shared-watchOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.release.xcconfig"; sourceTree = ""; }; 465BC1ED2D8DB87A00A30A60 /* LocationHistoryEntryListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationHistoryEntryListItemView.swift; sourceTree = ""; }; 4697F4BE2D8A3F7500C5C467 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -3016,7 +3039,7 @@ 50D9C22ED2834EC9DAAC63AC /* Pods-iOS-Extensions-Intents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.debug.xcconfig"; sourceTree = ""; }; 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6563AFB7BDAF57478CA18D9B /* Pods-iOS-Extensions-PushProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.debug.xcconfig"; sourceTree = ""; }; @@ -3033,7 +3056,7 @@ 7C3CCF89D04DB409DDFC4A09 /* Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; sourceTree = ""; }; 7DC07BDAC69AD95BDEFD8AFF /* Pods-iOS-Extensions-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.release.xcconfig"; sourceTree = ""; }; 86BFD63671D2D0A012DFE169 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; 8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; }; 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; }; @@ -3045,7 +3068,7 @@ 9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; }; 9D84964A844E6CD21F16D3AB /* Pods-watchOS-WatchExtension-Watch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; sourceTree = ""; }; 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFolderRow.swift; sourceTree = ""; }; - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; 9F9398CFD66E4C66DC39E1D3 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; }; A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthError.test.swift; sourceTree = ""; }; AA48C686F844D08C426A8D74 /* Pods_Tests_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tests_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3367,7 +3390,7 @@ B6FD0573228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationContent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BEF9A7008EFA4A6FC9E02B5E /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; C1694CFE2AAC39ABD6266B1D /* Pods-iOS-Extensions-Matter.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Matter.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Matter/Pods-iOS-Extensions-Matter.release.xcconfig"; sourceTree = ""; }; @@ -4694,6 +4717,7 @@ 420CFC7B2D3F9CCB009A94F3 /* AppPanelTable.swift */, 4201FD9A2F0E938E00C3EF1C /* CameraListConfigurationTable.swift */, 4210CCFE2F155B7900B71FB9 /* AssistConfigurationTable.swift */, + 14444A34DA125693568C7035 /* KioskSettingsTable.swift */, ); path = Tables; sourceTree = ""; @@ -5814,6 +5838,7 @@ children = ( 11DE822F24FAE66F00E636B8 /* UIWindow+Additions.swift */, 4228CFFE2DB8E7DD00FC6912 /* WebViewScriptMessageHandler.swift */, + 09A39058730A6B3E876D8848 /* WebViewController+Kiosk.swift */, 4206FFB32DAD58140087626C /* WebViewController+PostOnboarding.swift */, 42E00D122E1E7807006D140D /* NotificationPermissionRequestView.swift */, 4206FFB52DAD58520087626C /* WebViewGestureHandler.swift */, @@ -6349,6 +6374,53 @@ path = TodoList; sourceTree = ""; }; + 5F7F99C4E4A98B841C0969B6 /* Kiosk */ = { + isa = PBXGroup; + children = ( + 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */, + C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */, + 402432B9CC897C6278B08A79 /* KioskSettings.swift */, + 82F2464E2BC5B4C6E667087B /* Overlay */, + 4C1A049B16335C08AECDAAC2 /* Screensaver */, + D6500FB6C2035F49B7421ED9 /* Settings */, + ); + path = Kiosk; + sourceTree = ""; + }; + D6500FB6C2035F49B7421ED9 /* Settings */ = { + isa = PBXGroup; + children = ( + F49767602CA2066683EC638F /* KioskSettingsView.swift */, + C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 4C1A049B16335C08AECDAAC2 /* Screensaver */ = { + isa = PBXGroup; + children = ( + 38D793A057CBFD32530A7193 /* KioskClockScreensaverView.swift */, + 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */, + ); + path = Screensaver; + sourceTree = ""; + }; + 82F2464E2BC5B4C6E667087B /* Overlay */ = { + isa = PBXGroup; + children = ( + FCE1C6F8FA2181C936758465 /* KioskSecretExitGestureView.swift */, + ); + path = Overlay; + sourceTree = ""; + }; + 06D62F8A8D381DAFB70C6B31 /* Kiosk */ = { + isa = PBXGroup; + children = ( + EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */, + ); + path = Kiosk; + sourceTree = ""; + }; 46C3EB192D721000009A893F /* Utilities */ = { isa = PBXGroup; children = ( @@ -6711,6 +6783,7 @@ 11EFCDD424F5FA7E00314D85 /* Scenes */, 11AF1ED82528FBAA00AAE364 /* Onboarding */, 11AD2E2A2528FDB700FBC437 /* Frontend */, + 5F7F99C4E4A98B841C0969B6 /* Kiosk */, 42462E6B2DA3D05500ECC8A7 /* PermissionScreen */, 42B94BD92B9606CD00DEE060 /* Assist */, 42FCCFDD2B9B1AB00057783F /* BarcodeScanner */, @@ -6738,6 +6811,7 @@ 4272B9A72CDCE140008CC262 /* CarPlay */, 46F103242D7214F7002BC586 /* ClientEvents */, 42E3B8BB2D8ACDC100F5D084 /* Extensions */, + 06D62F8A8D381DAFB70C6B31 /* Kiosk */, 119C786625CF845800D41734 /* LocalizedStrings.test.swift */, 424151FE2CD90CA200D7A6F9 /* MagicItem */, 42196AD92DA5AF7A00BD501E /* Onboarding */, @@ -7095,10 +7169,10 @@ 11CB98CC249E637300B05222 /* Version+HA.test.swift */, 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */, - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */, - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */, - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */, + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */, + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */, + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */, + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */, 11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */, 11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */, 1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */, @@ -7950,7 +8024,7 @@ packageReferences = ( 420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */, - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */, + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */, 4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 42B18FD52F38CA2300A1537A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 42E5E2E42F38CEA30030BBEB /* XCRemoteSwiftPackageReference "SwiftMessages" */, @@ -8480,14 +8554,10 @@ 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"; @@ -8625,14 +8695,10 @@ 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"; @@ -8668,14 +8734,10 @@ 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"; @@ -8775,14 +8837,10 @@ 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"; @@ -9460,6 +9518,15 @@ 42F1DA632B4D54CB002729BC /* CarPlayTemplateProvider.swift in Sources */, 4206FFBA2DAD58DB0087626C /* ConnectionInfo+WebView.swift in Sources */, 42C0FD0A2DDB2047001016D6 /* AVMetadataObject.ObjectType+HA.swift in Sources */, + 2281600CB792D88059A71F4C /* KioskModeManager.swift in Sources */, + D46379541BA5FD96D6E7D328 /* KioskSettings.swift in Sources */, + 51B609B13F56C4F17CEF2BE4 /* KioskConstants.swift in Sources */, + B6DDF8534A4176416CFAC79A /* KioskSettingsView.swift in Sources */, + D8B4F2A61E9C73058AF2D49E /* KioskSettingsViewModel.swift in Sources */, + E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */, + 19F4C1F65664780F4E721699 /* KioskClockScreensaverView.swift in Sources */, + CB4D44CC6DBA5176155E157E /* KioskSecretExitGestureView.swift in Sources */, + 12FC58D695EB7AF41673476E /* WebViewController+Kiosk.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9539,6 +9606,7 @@ 11A71C7324A4FC8A00D9565F /* ZoneManagerEquatableRegion.test.swift in Sources */, 429481EB2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift in Sources */, 119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */, + C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9627,6 +9695,7 @@ 1110836924AFEFA60027A67A /* Promise+WebhookJson.swift in Sources */, 4298587F2EB1025E00E33710 /* LocationManager.swift in Sources */, 420CFC6A2D3F9C40009A94F3 /* WatchConfigTable.swift in Sources */, + 072BACCC5B2509E4AF06BFED /* KioskSettingsTable.swift in Sources */, 1164D9DF25FB1B9800515E8A /* UIBarButtonItem+Additions.swift in Sources */, 11B38EF6275C54A300205C7B /* PickAServerError.swift in Sources */, 426D9C752C9C60B000F278AF /* ControlEntityProvider.swift in Sources */, @@ -10163,6 +10232,7 @@ D0B25BD62133128800678C2C /* UNNotificationContent+ClientEvent.swift in Sources */, 118261F724F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */, 11C8E8AE24F3778E003E7F89 /* DeviceWrapper.swift in Sources */, + 87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -10182,10 +10252,10 @@ 11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */, 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */, - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */, - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */, - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */, + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */, + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */, + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */, + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */, 11267D0925BBA9FE00F28E5C /* Updater.test.swift in Sources */, 11A3F08C24ECE88C0018D84F /* WebhookUpdateLocation.test.swift in Sources */, 42FDCA272F0C7EB900C92958 /* EntityRegistry.test.swift in Sources */, @@ -12067,7 +12137,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12139,7 +12209,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Sources/App/Frontend/Extensions/WebViewController+Kiosk.swift b/Sources/App/Frontend/Extensions/WebViewController+Kiosk.swift new file mode 100644 index 000000000..7426ba00e --- /dev/null +++ b/Sources/App/Frontend/Extensions/WebViewController+Kiosk.swift @@ -0,0 +1,30 @@ +import Shared +import UIKit + +// MARK: - Kiosk Mode Extension + +extension WebViewController { + /// Setup kiosk mode integration with KioskModeManager + /// Call this from viewDidLoad + func setupKioskMode() { + KioskModeManager.shared.setup(using: self) + } + + // MARK: - Status Bar & Home Indicator + + var kioskPrefersStatusBarHidden: Bool { + KioskModeManager.shared.prefersStatusBarHidden + } + + var kioskPrefersHomeIndicatorAutoHidden: Bool { + KioskModeManager.shared.prefersHomeIndicatorAutoHidden + } + + // MARK: - Touch Handling + + /// Record user touch activity to reset the screensaver idle timer + /// Required because WKWebView consumes touch events before UIKit idle detection + func recordKioskActivity() { + KioskModeManager.shared.recordActivity(source: "touch") + } +} diff --git a/Sources/App/Frontend/WebView/WebViewController.swift b/Sources/App/Frontend/WebView/WebViewController.swift index da512e724..f01bd84af 100644 --- a/Sources/App/Frontend/WebView/WebViewController.swift +++ b/Sources/App/Frontend/WebView/WebViewController.swift @@ -1,5 +1,6 @@ import AVFoundation import AVKit +import Combine import CoreLocation import HAKit import Improv_iOS @@ -69,11 +70,11 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent override var prefersStatusBarHidden: Bool { - Current.settingsStore.fullScreen + Current.settingsStore.fullScreen || kioskPrefersStatusBarHidden } override var prefersHomeIndicatorAutoHidden: Bool { - Current.settingsStore.fullScreen + Current.settingsStore.fullScreen || kioskPrefersHomeIndicatorAutoHidden } override var preferredStatusBarStyle: UIStatusBarStyle { @@ -263,6 +264,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg postOnboardingNotificationPermission() emptyStateObservations() checkForLocalSecurityLevelDecisionNeeded() + setupKioskMode() } // Workaround for webview rotation issues: https://github.com/Telerik-Verified-Plugins/WKWebView/pull/263 @@ -282,6 +284,12 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) updateDatabaseAndPanels() + + // Refresh kiosk status bar state when view appears (e.g., after settings modal dismisses) + if KioskModeManager.shared.isKioskModeActive { + setNeedsStatusBarAppearanceUpdate() + navigationController?.setNeedsStatusBarAppearanceUpdate() + } } override func viewWillDisappear(_ animated: Bool) { diff --git a/Sources/App/Frontend/WebView/WebViewWindowController.swift b/Sources/App/Frontend/WebView/WebViewWindowController.swift index 0e69a5bcb..77cda1093 100644 --- a/Sources/App/Frontend/WebView/WebViewWindowController.swift +++ b/Sources/App/Frontend/WebView/WebViewWindowController.swift @@ -5,6 +5,22 @@ import Shared import SwiftUI import UIKit +/// Navigation controller that forwards status bar and home indicator preferences to its top view controller. +/// This is needed for kiosk mode to properly hide the status bar when WebViewController is embedded. +final class StatusBarForwardingNavigationController: UINavigationController { + override var childForStatusBarHidden: UIViewController? { + topViewController + } + + override var childForStatusBarStyle: UIViewController? { + topViewController + } + + override var childForHomeIndicatorAutoHidden: UIViewController? { + topViewController + } +} + final class WebViewWindowController { enum RootViewControllerType { case onboarding @@ -55,7 +71,7 @@ final class WebViewWindowController { } private func webViewNavigationController(rootViewController: UIViewController? = nil) -> UINavigationController { - let navigationController = UINavigationController() + let navigationController = StatusBarForwardingNavigationController() navigationController.setNavigationBarHidden(true, animated: false) if let rootViewController { diff --git a/Sources/App/Kiosk/KioskConstants.swift b/Sources/App/Kiosk/KioskConstants.swift new file mode 100644 index 000000000..98e72d24f --- /dev/null +++ b/Sources/App/Kiosk/KioskConstants.swift @@ -0,0 +1,38 @@ +import CoreGraphics +import Foundation +import Shared + +// MARK: - Kiosk Constants + +/// Centralized constants for the kiosk mode +public enum KioskConstants { + // MARK: - Animation Durations + + public enum Animation { + /// Standard transition animation duration + public static let standard: TimeInterval = 0.3 + /// Quick animation for subtle transitions + public static let quick: TimeInterval = 0.2 + /// Slow animation for screensaver transitions + public static let slow: TimeInterval = 0.5 + /// Pixel shift animation duration + public static let pixelShift: TimeInterval = 1.0 + } + + // MARK: - UI Dimensions + + public enum UI { + /// Standard corner radius (uses DesignSystem) + public static let cornerRadius: CGFloat = DesignSystem.CornerRadius.oneAndHalf + /// Small corner radius (uses DesignSystem) + public static let smallCornerRadius: CGFloat = DesignSystem.CornerRadius.one + /// Large clock font size + public static let largeClockFontSize: CGFloat = 120 + /// Minimal clock font size + public static let minimalClockFontSize: CGFloat = 80 + /// Digital clock font size + public static let digitalClockFontSize: CGFloat = 100 + /// Analog clock size + public static let analogClockSize: CGFloat = 300 + } +} diff --git a/Sources/App/Kiosk/KioskModeManager.swift b/Sources/App/Kiosk/KioskModeManager.swift new file mode 100644 index 000000000..d4aca7c70 --- /dev/null +++ b/Sources/App/Kiosk/KioskModeManager.swift @@ -0,0 +1,676 @@ +import Combine +import Foundation +import Shared +import SwiftUI +import UIKit + +// MARK: - Kiosk Mode Observer Protocol + +/// Protocol for observing kiosk mode changes +/// Implement this protocol and register with KioskModeManager to receive updates +public protocol KioskModeObserver: AnyObject { + /// Called when kiosk mode is enabled or disabled + func kioskModeDidChange(isActive: Bool) + + /// Called when kiosk settings change + func kioskSettingsDidChange(_ settings: KioskSettings) + + /// Called when screen state changes (on/dimmed/screensaver/off) + func kioskScreenStateDidChange(_ state: ScreenState) + + /// Called when pixel shift should be applied (for OLED burn-in prevention) + func kioskPixelShiftDidTrigger(amount: CGFloat) +} + +/// Default implementations make all methods optional +public extension KioskModeObserver { + func kioskModeDidChange(isActive: Bool) {} + func kioskSettingsDidChange(_ settings: KioskSettings) {} + func kioskScreenStateDidChange(_ state: ScreenState) {} + func kioskPixelShiftDidTrigger(amount: CGFloat) {} +} + +// MARK: - Kiosk Mode Manager + +/// Central manager for kiosk mode functionality +/// Coordinates screen state, screensaver, brightness control, and WebViewController integration +@MainActor +public final class KioskModeManager: ObservableObject { + // MARK: - Singleton + + public static let shared = KioskModeManager() + + // MARK: - Published State + + /// Current kiosk settings (persisted) + @Published public private(set) var settings: KioskSettings { + didSet { + saveSettings() + settingsDidChange(from: oldValue, to: settings) + } + } + + /// Whether kiosk mode is currently active + @Published public private(set) var isKioskModeActive: Bool = false + + /// Current screen state + @Published public private(set) var screenState: ScreenState = .on + + /// Current screensaver mode (when active) + @Published public private(set) var activeScreensaverMode: ScreensaverMode? + + /// Current brightness level (0.0 - 1.0) + @Published public private(set) var currentBrightness: Float = 0.8 + + /// App state (active or background) + @Published public private(set) var appState: AppState = .active + + /// Last wake source + @Published public private(set) var lastWakeSource: String = "launch" + + /// Last user activity timestamp + @Published public private(set) var lastActivityTime: Date = Current.date() + + /// Pixel shift trigger counter - observe this in SwiftUI to trigger pixel shift + @Published public private(set) var pixelShiftTrigger: Int = 0 + + // MARK: - WebViewController Integration + + weak var webViewController: WebViewControllerProtocol? + private var screensaverController: KioskScreensaverViewController? + private var secretExitGestureController: KioskSecretExitGestureViewController? + + // MARK: - Private Properties + + private var idleTimer: Timer? + private var pixelShiftTimer: Timer? + private var originalBrightness: Float? + private var preScreensaverBrightness: CGFloat? + private var isIdleTimerPaused = false + private var saveDebounceTimer: Timer? + + /// Weak wrapper for observers to avoid retain cycles + private class WeakObserver { + weak var observer: KioskModeObserver? + init(_ observer: KioskModeObserver) { + self.observer = observer + } + } + + /// Registered observers + private var observers: [WeakObserver] = [] + + // MARK: - Observer Management + + /// Register an observer to receive kiosk mode updates + public func addObserver(_ observer: KioskModeObserver) { + // Clean up any nil references while adding + observers.removeAll { $0.observer == nil } + + // Check if already registered + guard !observers.contains(where: { $0.observer === observer }) else { return } + + observers.append(WeakObserver(observer)) + } + + /// Unregister an observer + public func removeObserver(_ observer: KioskModeObserver) { + observers.removeAll { $0.observer === observer || $0.observer == nil } + } + + /// Remove observers whose weak references have become nil + private func pruneNilObservers() { + observers.removeAll { $0.observer == nil } + } + + /// Notify all observers of kiosk mode change + private func notifyObserversOfModeChange() { + pruneNilObservers() + observers.forEach { $0.observer?.kioskModeDidChange(isActive: isKioskModeActive) } + } + + /// Notify all observers of settings change + private func notifyObserversOfSettingsChange() { + pruneNilObservers() + observers.forEach { $0.observer?.kioskSettingsDidChange(settings) } + } + + /// Notify all observers of screen state change + private func notifyObserversOfScreenStateChange() { + pruneNilObservers() + observers.forEach { $0.observer?.kioskScreenStateDidChange(screenState) } + } + + /// Notify all observers of pixel shift + private func notifyObserversOfPixelShift() { + pruneNilObservers() + let amount = settings.pixelShiftAmount + observers.forEach { $0.observer?.kioskPixelShiftDidTrigger(amount: amount) } + } + + // MARK: - Initialization + + private init() { + self.settings = Self.loadSettings() + setupAppLifecycleObservers() + Current.Log.info("KioskModeManager initialized") + } + + deinit { + idleTimer?.invalidate() + pixelShiftTimer?.invalidate() + saveDebounceTimer?.invalidate() + NotificationCenter.default.removeObserver(self) + } + + // MARK: - WebViewController Setup + + /// Setup kiosk mode integration with the WebViewController + /// Call this from WebViewController.viewDidLoad + func setup(using webViewController: WebViewControllerProtocol) { + self.webViewController = webViewController + + guard let viewController = webViewController as? UIViewController else { return } + + // Setup secret exit gesture overlay (always available when kiosk mode is active) + setupSecretExitGesture(in: viewController) + + // Apply initial state if already in kiosk mode + if isKioskModeActive { + updateKioskModeLockdown(enabled: true) + } + } + + // MARK: - Status Bar & Home Indicator + + /// Whether kiosk mode wants the status bar hidden + var prefersStatusBarHidden: Bool { + isKioskModeActive && settings.hideStatusBar + } + + /// Whether kiosk mode wants the home indicator hidden + var prefersHomeIndicatorAutoHidden: Bool { + isKioskModeActive + } + + // MARK: - Public Methods + + /// Enable kiosk mode + public func enableKioskMode() { + guard !isKioskModeActive else { return } + + Current.Log.info("Enabling kiosk mode") + isKioskModeActive = true + + // Persist enabled state so it survives app restart + if !settings.isKioskModeEnabled { + var updated = settings + updated.isKioskModeEnabled = true + settings = updated + } + + // Store original brightness to restore later + originalBrightness = Float(Current.screenBrightness()) + + // Prevent iOS from auto-locking the screen + if settings.preventAutoLock { + Current.application?().isIdleTimerDisabled = true + Current.Log.info("Screen auto-lock disabled") + } + + // Apply brightness + applyBrightness() + startIdleTimer() + + updateKioskModeLockdown(enabled: true) + notifyObserversOfModeChange() + } + + /// Disable kiosk mode + public func disableKioskMode() { + guard isKioskModeActive else { return } + + Current.Log.info("Disabling kiosk mode") + isKioskModeActive = false + + // Persist disabled state + if settings.isKioskModeEnabled { + var updated = settings + updated.isKioskModeEnabled = false + settings = updated + } + + // Restore original brightness + if let original = originalBrightness { + Current.setScreenBrightness(CGFloat(original)) + } + + // Re-enable iOS auto-lock if kiosk mode had disabled it + if settings.preventAutoLock { + Current.application?().isIdleTimerDisabled = false + Current.Log.info("Screen auto-lock restored") + } + + // Stop timers + stopIdleTimer() + stopPixelShiftTimer() + + // Hide screensaver if active + hideScreensaver(source: "kiosk_disabled") + + updateKioskModeLockdown(enabled: false) + notifyObserversOfModeChange() + } + + /// Update settings + public func updateSettings(_ newSettings: KioskSettings) { + settings = newSettings + } + + /// Update a single setting using a closure + public func updateSettings(_ update: (inout KioskSettings) -> Void) { + var newSettings = settings + update(&newSettings) + settings = newSettings + } + + /// Record user activity (touch, etc.) + public func recordActivity(source: String = "touch") { + lastActivityTime = Current.date() + lastWakeSource = source + + // Reset idle timer + if isKioskModeActive { + startIdleTimer() + } + + // If screensaver is active, wake on touch + if screenState != .on, source == "touch" { + wakeScreen(source: source) + } + } + + /// Pause the idle timer (e.g., when settings view is open) + public func pauseIdleTimer() { + isIdleTimerPaused = true + stopIdleTimer() + Current.Log.verbose("Idle timer paused") + } + + /// Resume the idle timer + public func resumeIdleTimer() { + isIdleTimerPaused = false + if isKioskModeActive { + startIdleTimer() + } + Current.Log.verbose("Idle timer resumed") + } + + // MARK: - Screen Control + + /// Wake the screen (exit screensaver, restore brightness) + public func wakeScreen(source: String) { + guard screenState != .on else { return } + + Current.Log.info("Waking screen from source: \(source)") + lastWakeSource = source + lastActivityTime = Current.date() + + hideScreensaver(source: source) + + // Restore brightness: use managed level if enabled, otherwise restore pre-screensaver level + if settings.brightnessControlEnabled { + applyBrightness() + } else if let savedBrightness = preScreensaverBrightness { + Current.setScreenBrightness(savedBrightness) + } + preScreensaverBrightness = nil + + screenState = .on + notifyObserversOfScreenStateChange() + + startIdleTimer() + } + + /// Put screen to sleep (start screensaver) + public func sleepScreen(mode: ScreensaverMode? = nil) { + let screensaverMode = mode ?? settings.screensaverMode + + Current.Log.info("Sleeping screen with mode: \(screensaverMode)") + stopIdleTimer() + + showScreensaver(mode: screensaverMode) + } + + /// Set brightness level (0-100) + public func setBrightness(_ level: Int) { + let clampedLevel = max(0, min(100, level)) + let brightness = Float(clampedLevel) / 100.0 + + Current.Log.info("Setting brightness to \(clampedLevel)%") + currentBrightness = brightness + Current.setScreenBrightness(CGFloat(brightness)) + } + + /// Refresh current page + public func refresh() { + Current.Log.info("Refreshing current page") + webViewController?.refresh() + } + + /// Called when app returns to foreground + public func appDidBecomeActive() { + appState = .active + } + + /// Called when app enters background + public func appDidEnterBackground() { + appState = .background + } + + // MARK: - Screensaver State + + private func showScreensaver(mode: ScreensaverMode) { + activeScreensaverMode = mode + + // Save current brightness so wakeScreen can restore it + preScreensaverBrightness = Current.screenBrightness() + + switch mode { + case .blank: + screenState = .off + Current.setScreenBrightness(0) + + case .dim: + screenState = .dimmed + Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) + + case .clock: + screenState = .screensaver + if settings.screensaverDimLevel < currentBrightness { + Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) + } + } + + if settings.pixelShiftEnabled { + startPixelShiftTimer() + } + + presentScreensaverViewController(mode: mode) + notifyObserversOfScreenStateChange() + } + + private func hideScreensaver(source: String) { + guard activeScreensaverMode != nil else { return } + + Current.Log.info("Hiding screensaver (source: \(source))") + activeScreensaverMode = nil + stopPixelShiftTimer() + + dismissScreensaverViewController() + } + + // MARK: - Screensaver View Controller + + private func presentScreensaverViewController(mode: ScreensaverMode) { + guard let parentVC = webViewController as? UIViewController else { return } + + // Dismiss any existing screensaver first + if let existing = screensaverController { + existing.dismiss(animated: false) + screensaverController = nil + } + + Current.Log.info("Showing screensaver: \(mode.rawValue)") + + let controller = KioskScreensaverViewController() + screensaverController = controller + + controller.onShowSettings = { [weak self] in + self?.showKioskSettings() + } + + controller.loadViewIfNeeded() + controller.configure(mode: mode) + controller.modalPresentationStyle = .overFullScreen + controller.modalTransitionStyle = .crossDissolve + parentVC.present(controller, animated: true) + } + + private func dismissScreensaverViewController() { + guard let controller = screensaverController else { return } + + Current.Log.info("Hiding screensaver") + controller.dismiss(animated: true) { [weak self] in + self?.screensaverController = nil + } + } + + // MARK: - Secret Exit Gesture + + private func setupSecretExitGesture(in parentController: UIViewController) { + let controller = KioskSecretExitGestureViewController() + secretExitGestureController = controller + + controller.onShowSettings = { [weak self] in + self?.showKioskSettings() + } + + parentController.addChild(controller) + parentController.view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: parentController.view.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: parentController.view.bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: parentController.view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: parentController.view.trailingAnchor), + ]) + + controller.didMove(toParent: parentController) + } + + // MARK: - UI Lockdown + + private func updateKioskModeLockdown(enabled: Bool) { + guard let viewController = webViewController as? WebViewController else { return } + + // Update iOS system status bar and home indicator visibility + if let navController = viewController.navigationController { + navController.setNeedsStatusBarAppearanceUpdate() + navController.setNeedsUpdateOfHomeIndicatorAutoHidden() + } + viewController.setNeedsStatusBarAppearanceUpdate() + viewController.setNeedsUpdateOfHomeIndicatorAutoHidden() + + // Hide/show the custom status bar background view + if let statusBarView = viewController.statusBarView { + let shouldHide = enabled && settings.hideStatusBar + statusBarView.isHidden = shouldHide + } + } + + // MARK: - Settings UI + + private func showKioskSettings() { + guard webViewController != nil else { return } + + // Dismiss screensaver first if it's showing (settings should appear over WebView) + if let screensaver = screensaverController { + screensaver.dismiss(animated: false) { [weak self] in + self?.screensaverController = nil + self?.presentSettingsModal() + } + } else { + presentSettingsModal() + } + } + + private func presentSettingsModal() { + Current.Log.info("Showing kiosk settings") + + let settingsView = KioskSettingsView(onDismiss: { [weak self] in + guard let webVC = self?.webViewController as? UIViewController else { return } + webVC.dismiss(animated: true) { [weak self] in + self?.refreshStatusBarAppearance() + } + }) + let hostingController = UIHostingController(rootView: settingsView) + let navController = UINavigationController(rootViewController: hostingController) + webViewController?.presentOverlayController(controller: navController, animated: true) + } + + /// Force a complete status bar appearance refresh after modal dismissal + private func refreshStatusBarAppearance() { + guard let viewController = webViewController as? WebViewController else { return } + viewController.navigationController?.setNeedsStatusBarAppearanceUpdate() + viewController.setNeedsStatusBarAppearanceUpdate() + viewController.navigationController?.setNeedsUpdateOfHomeIndicatorAutoHidden() + viewController.setNeedsUpdateOfHomeIndicatorAutoHidden() + } + + // MARK: - Idle Timer + + private func startIdleTimer() { + stopIdleTimer() + + // Don't start if paused (e.g., settings view is open) + guard !isIdleTimerPaused else { return } + guard settings.screensaverEnabled else { return } + + let timeout = settings.screensaverTimeout + guard timeout > 0 else { return } + + idleTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleIdleTimeout() + } + } + + Current.Log.verbose("Started idle timer: \(Int(timeout))s") + } + + private func stopIdleTimer() { + idleTimer?.invalidate() + idleTimer = nil + } + + private func handleIdleTimeout() { + Current.Log.info("Idle timeout reached") + sleepScreen() + } + + // MARK: - Brightness + + private func applyBrightness() { + guard settings.brightnessControlEnabled else { return } + + currentBrightness = settings.manualBrightness + Current.setScreenBrightness(CGFloat(settings.manualBrightness)) + } + + // MARK: - Pixel Shift Timer + + private func startPixelShiftTimer() { + stopPixelShiftTimer() + + let interval = settings.pixelShiftInterval + guard interval > 0 else { return } + + pixelShiftTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.triggerPixelShift() + } + } + } + + private func stopPixelShiftTimer() { + pixelShiftTimer?.invalidate() + pixelShiftTimer = nil + } + + private func triggerPixelShift() { + pixelShiftTrigger += 1 + notifyObserversOfPixelShift() + } + + // MARK: - Settings Persistence + + private static func loadSettings() -> KioskSettings { + KioskSettingsRecord.settings() + } + + private func saveSettings() { + // Debounce database writes to avoid rapid persistence during slider drags + saveDebounceTimer?.invalidate() + saveDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + KioskSettingsRecord.save(settings) + } + } + } + + private func settingsDidChange(from oldValue: KioskSettings, to newValue: KioskSettings) { + guard isKioskModeActive else { + notifyObserversOfSettingsChange() + return + } + + // Reapply brightness if setting changed + if oldValue.manualBrightness != newValue.manualBrightness { + applyBrightness() + } + + // Restart idle timer if timeout changed + if oldValue.screensaverTimeout != newValue.screensaverTimeout, + screenState == .on { + startIdleTimer() + } + + // Apply preventAutoLock changes immediately + if oldValue.preventAutoLock != newValue.preventAutoLock { + Current.application?().isIdleTimerDisabled = newValue.preventAutoLock + } + + // Apply screensaver enabled/disabled changes immediately + if oldValue.screensaverEnabled != newValue.screensaverEnabled { + if newValue.screensaverEnabled { + if screenState == .on { + startIdleTimer() + } + } else { + stopIdleTimer() + } + } + + updateKioskModeLockdown(enabled: true) + notifyObserversOfSettingsChange() + } + + // MARK: - App Lifecycle Observers + + private func setupAppLifecycleObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + @objc private func handleAppDidBecomeActive() { + appDidBecomeActive() + } + + @objc private func handleAppDidEnterBackground() { + appDidEnterBackground() + } +} diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift new file mode 100644 index 000000000..01f6e2da6 --- /dev/null +++ b/Sources/App/Kiosk/KioskSettings.swift @@ -0,0 +1,221 @@ +import Foundation +import GRDB +import Shared +import UIKit + +// MARK: - GRDB Record Wrapper + +public struct KioskSettingsRecord: Codable, FetchableRecord, PersistableRecord { + public static var databaseTableName: String { GRDBDatabaseTable.kioskSettings.rawValue } + public static let recordId = "kiosk-settings" + + public var id: String = KioskSettingsRecord.recordId + public var settingsJSON: KioskSettings = .init() + + public static func settings() -> KioskSettings { + do { + let record: KioskSettingsRecord? = try Current.database().read { db in + try KioskSettingsRecord.fetchOne(db, key: KioskSettingsRecord.recordId) + } + if let record { + return record.settingsJSON + } else { + Current.Log.info("No saved kiosk settings found in GRDB, using defaults") + return KioskSettings() + } + } catch { + Current.Log.error("Failed to load kiosk settings from GRDB: \(error)") + return KioskSettings() + } + } + + public static func save(_ settings: KioskSettings) { + do { + var record = KioskSettingsRecord() + record.settingsJSON = settings + try Current.database().write { db in + try record.insert(db, onConflict: .replace) + } + Current.Log.verbose("Saved kiosk settings to GRDB") + } catch { + Current.Log.error("Failed to save kiosk settings to GRDB: \(error)") + } + } +} + +// MARK: - Main Settings Container + +/// Complete settings model for kiosk mode +/// All settings are Codable for persistence and HA integration sync +public struct KioskSettings: Codable, Equatable { + // MARK: - Core Kiosk Mode + + /// Whether kiosk mode is currently enabled + public var isKioskModeEnabled: Bool = false + + /// Whether device authentication (Face ID, Touch ID, or passcode) is required to access settings + public var requireDeviceAuthentication: Bool = false + + /// Hide iOS status bar for full immersion + public var hideStatusBar: Bool = true + + /// Prevent iOS from auto-locking the screen + public var preventAutoLock: Bool = true + + // MARK: - Brightness Control + + /// Enable brightness management + public var brightnessControlEnabled: Bool = true + + /// Manual brightness level (0.0 - 1.0) + public var manualBrightness: Float = 0.8 + + // MARK: - Screensaver + + /// Enable screensaver + public var screensaverEnabled: Bool = true + + /// Screensaver mode + public var screensaverMode: ScreensaverMode = .clock + + /// Seconds of idle before screensaver activates + public var screensaverTimeout: TimeInterval = 300 // 5 minutes + + /// Brightness level when dimmed (0.0 - 1.0) + public var screensaverDimLevel: Float = 0.1 + + /// Enable pixel shifting for OLED burn-in prevention + public var pixelShiftEnabled: Bool = true + + /// Pixel shift amount in points + public var pixelShiftAmount: CGFloat = 10 + + /// Pixel shift interval in seconds + public var pixelShiftInterval: TimeInterval = 60 + + // MARK: - Screensaver Clock Options + + /// Show seconds on clock + public var clockShowSeconds: Bool = false + + /// Show date on clock + public var clockShowDate: Bool = true + + /// Use 24-hour time format (false = 12-hour with AM/PM) + public var clockUse24HourFormat: Bool = true + + /// Clock style + public var clockStyle: ClockStyle = .large + + // MARK: - Secret Exit Gesture + + /// Enable secret gesture to access kiosk settings (escape hatch) + public var secretExitGestureEnabled: Bool = true + + /// Corner for secret exit gesture + public var secretExitGestureCorner: ScreenCorner = .bottomRight + + /// Number of taps required for secret exit gesture + public var secretExitGestureTaps: Int = 3 +} + +// MARK: - Enums + +public enum ScreensaverMode: String, Codable, CaseIterable { + case blank + case dim + case clock + + public var displayName: String { + switch self { + case .blank: return L10n.Kiosk.Screensaver.Mode.blank + case .dim: return L10n.Kiosk.Screensaver.Mode.dim + case .clock: return L10n.Kiosk.Screensaver.Mode.clock + } + } +} + +public enum ScreensaverTimeout: CaseIterable { + case thirtySeconds + case oneMinute + case twoMinutes + case fiveMinutes + case tenMinutes + case fifteenMinutes + case thirtyMinutes + + public var timeInterval: TimeInterval { + switch self { + case .thirtySeconds: return 30 + case .oneMinute: return 60 + case .twoMinutes: return 120 + case .fiveMinutes: return 300 + case .tenMinutes: return 600 + case .fifteenMinutes: return 900 + case .thirtyMinutes: return 1800 + } + } + + public var displayName: String { + switch self { + case .thirtySeconds: return L10n.Kiosk.Screensaver.Timeout._30sec + case .oneMinute: return L10n.Kiosk.Screensaver.Timeout._1min + case .twoMinutes: return L10n.Kiosk.Screensaver.Timeout._2min + case .fiveMinutes: return L10n.Kiosk.Screensaver.Timeout._5min + case .tenMinutes: return L10n.Kiosk.Screensaver.Timeout._10min + case .fifteenMinutes: return L10n.Kiosk.Screensaver.Timeout._15min + case .thirtyMinutes: return L10n.Kiosk.Screensaver.Timeout._30min + } + } + + /// Initialize from a TimeInterval, defaulting to fiveMinutes if no match + public init(from interval: TimeInterval) { + self = Self.allCases.first { $0.timeInterval == interval } ?? .fiveMinutes + } +} + +public enum ClockStyle: String, Codable, CaseIterable { + case large + case minimal + case analog + case digital + + public var displayName: String { + switch self { + case .large: return L10n.Kiosk.Clock.Style.large + case .minimal: return L10n.Kiosk.Clock.Style.minimal + case .analog: return L10n.Kiosk.Clock.Style.analog + case .digital: return L10n.Kiosk.Clock.Style.digital + } + } +} + +// MARK: - Screen State (for sensors) + +public enum ScreenState: String, Codable { + case on + case dimmed + case screensaver + case off +} + +public enum AppState: String, Codable { + case active + case background +} + +public enum ScreenCorner: String, Codable, CaseIterable { + case topLeft + case topRight + case bottomLeft + case bottomRight + + public var displayName: String { + switch self { + case .topLeft: return L10n.Kiosk.Corner.topLeft + case .topRight: return L10n.Kiosk.Corner.topRight + case .bottomLeft: return L10n.Kiosk.Corner.bottomLeft + case .bottomRight: return L10n.Kiosk.Corner.bottomRight + } + } +} diff --git a/Sources/App/Kiosk/Overlay/KioskSecretExitGestureView.swift b/Sources/App/Kiosk/Overlay/KioskSecretExitGestureView.swift new file mode 100644 index 000000000..5196800fd --- /dev/null +++ b/Sources/App/Kiosk/Overlay/KioskSecretExitGestureView.swift @@ -0,0 +1,236 @@ +import SwiftUI +import UIKit + +// MARK: - Kiosk Secret Exit Gesture View + +/// An invisible overlay that detects multi-tap gestures in a corner to access kiosk settings +/// This provides an escape hatch when navigation is locked down +public struct KioskSecretExitGestureView: View { + @ObservedObject private var kioskManager = KioskModeManager.shared + @Binding var showSettings: Bool + + /// Size of the tap target area in the corner + private let cornerTapSize: CGFloat = 80 + + public init(showSettings: Binding) { + _showSettings = showSettings + } + + public var body: some View { + Group { + if kioskManager.isKioskModeActive, kioskManager.settings.secretExitGestureEnabled { + cornerTapArea + } + } + } + + @ViewBuilder + private var cornerTapArea: some View { + let corner = kioskManager.settings.secretExitGestureCorner + let requiredTaps = kioskManager.settings.secretExitGestureTaps + + // Use a frame with alignment to position the tap area in the corner + // The tap area is the only thing that receives touches + VStack { + if corner == .bottomLeft || corner == .bottomRight { + Spacer(minLength: 0) + } + HStack { + if corner == .topRight || corner == .bottomRight { + Spacer(minLength: 0) + } + SecretTapArea(requiredTaps: requiredTaps) { + showSettings = true + } + .frame(width: cornerTapSize, height: cornerTapSize) + if corner == .topLeft || corner == .bottomLeft { + Spacer(minLength: 0) + } + } + if corner == .topLeft || corner == .topRight { + Spacer(minLength: 0) + } + } + .allowsHitTesting(true) + } +} + +// MARK: - Secret Tap Area + +/// A view that detects multiple rapid taps and triggers an action +private struct SecretTapArea: View { + let requiredTaps: Int + let onTriggered: () -> Void + + @State private var tapCount = 0 + @State private var resetTimer: Timer? + + /// Time window for completing all taps (seconds) + private let tapWindow: TimeInterval = 2.0 + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + handleTap() + } + .onDisappear { + resetTimer?.invalidate() + resetTimer = nil + } + } + + private func handleTap() { + // Cancel existing timer + resetTimer?.invalidate() + + tapCount += 1 + + if tapCount >= requiredTaps { + // Success - trigger action + tapCount = 0 + resetTimer = nil + onTriggered() + } else { + // Reset tap count if no more taps within window + resetTimer = Timer.scheduledTimer(withTimeInterval: tapWindow, repeats: false) { _ in + tapCount = 0 + } + } + } +} + +// MARK: - UIKit Integration + +/// A passthrough view that only intercepts touches in corner regions +private class CornerTapPassthroughView: UIView { + /// Size of the corner tap region + var cornerSize: CGFloat = 80 + /// Which corner is active + var activeCorner: ScreenCorner = .topLeft + /// Whether the gesture is enabled + var isGestureEnabled: Bool = true + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // If gesture is disabled, pass through everything + guard isGestureEnabled else { return nil } + + // Check if the point is in the active corner + let cornerRect = rectForCorner(activeCorner) + if cornerRect.contains(point) { + // Let the subview (SwiftUI) handle this tap + return super.hitTest(point, with: event) + } + + // Pass through touches outside the corner + return nil + } + + private func rectForCorner(_ corner: ScreenCorner) -> CGRect { + switch corner { + case .topLeft: + return CGRect(x: 0, y: 0, width: cornerSize, height: cornerSize) + case .topRight: + return CGRect(x: bounds.width - cornerSize, y: 0, width: cornerSize, height: cornerSize) + case .bottomLeft: + return CGRect(x: 0, y: bounds.height - cornerSize, width: cornerSize, height: cornerSize) + case .bottomRight: + return CGRect( + x: bounds.width - cornerSize, + y: bounds.height - cornerSize, + width: cornerSize, + height: cornerSize + ) + } + } +} + +/// A UIView wrapper for the secret exit gesture that can be added to UIKit view controllers +public class KioskSecretExitGestureViewController: UIViewController, KioskModeObserver { + private var hostingController: UIHostingController? + private var passthroughView: CornerTapPassthroughView? + + /// Callback when settings should be shown + public var onShowSettings: (() -> Void)? + + override public func loadView() { + let passthrough = CornerTapPassthroughView() + passthrough.backgroundColor = .clear + view = passthrough + passthroughView = passthrough + } + + override public func viewDidLoad() { + super.viewDidLoad() + setupGestureView() + setupSettingsObserver() + } + + private func setupGestureView() { + let wrapper = KioskSecretExitGestureWrapper { [weak self] in + self?.onShowSettings?() + } + + let hosting = UIHostingController(rootView: wrapper) + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hosting.didMove(toParent: self) + hostingController = hosting + + // Initial settings sync + updatePassthroughSettings() + } + + private func setupSettingsObserver() { + // Register as kiosk mode observer for settings and mode changes + KioskModeManager.shared.addObserver(self) + } + + // Note: No need to explicitly remove observer in deinit - KioskModeManager uses weak references + // that automatically clean up when this object is deallocated + + // MARK: - KioskModeObserver + + public func kioskModeDidChange(isActive: Bool) { + updatePassthroughSettings() + } + + public func kioskSettingsDidChange(_ settings: KioskSettings) { + updatePassthroughSettings() + } + + private func updatePassthroughSettings() { + let settings = KioskModeManager.shared.settings + let isActive = KioskModeManager.shared.isKioskModeActive + passthroughView?.isGestureEnabled = settings.secretExitGestureEnabled && isActive + passthroughView?.activeCorner = settings.secretExitGestureCorner + passthroughView?.cornerSize = 80 + } +} + +/// Internal wrapper that converts the binding-based API to a closure-based one +private struct KioskSecretExitGestureWrapper: View { + let onShowSettings: () -> Void + @State private var showSettings = false + + var body: some View { + KioskSecretExitGestureView(showSettings: $showSettings) + .onChange(of: showSettings) { newValue in + if newValue { + showSettings = false + onShowSettings() + } + } + } +} diff --git a/Sources/App/Kiosk/Screensaver/KioskClockScreensaverView.swift b/Sources/App/Kiosk/Screensaver/KioskClockScreensaverView.swift new file mode 100644 index 000000000..bb1d76e74 --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/KioskClockScreensaverView.swift @@ -0,0 +1,234 @@ +import Shared +import SwiftUI + +// MARK: - Kiosk Date Formatters + +/// Cached date formatters for clock display, reusable across the app +enum KioskDateFormatters { + static let time24h: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() + + static let time24hSeconds: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f + }() + + static let time12h: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "h:mm a" + return f + }() + + static let time12hSeconds: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "h:mm:ss a" + return f + }() + + static let accessibility: DateFormatter = { + let f = DateFormatter() + f.timeStyle = .short + return f + }() + + static let dateDisplay: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("EEEE MMMM d") + return f + }() +} + +// MARK: - Kiosk Clock Screensaver View + +/// A screensaver view displaying time with optional date +public struct KioskClockScreensaverView: View { + @ObservedObject private var manager = KioskModeManager.shared + @State private var currentTime = Current.date() + private let timeTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + public init() {} + + public var body: some View { + GeometryReader { _ in + VStack(spacing: DesignSystem.Spaces.two) { + Spacer() + + clockDisplay + + if manager.settings.clockShowDate { + dateDisplay + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color.black) + .onReceive(timeTimer) { _ in + currentTime = Current.date() + } + } + + // MARK: - Clock Display + + @ViewBuilder + private var clockDisplay: some View { + switch manager.settings.clockStyle { + case .large: + largeClockDisplay + + case .minimal: + minimalClockDisplay + + case .analog: + analogClockDisplay + + case .digital: + digitalClockDisplay + } + } + + private var largeClockDisplay: some View { + Text(timeString) + .font(.system(size: KioskConstants.UI.largeClockFontSize, weight: .thin, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + .accessibilityLabel(L10n.Kiosk.Clock.Accessibility.currentTime(accessibleTimeString)) + } + + private var minimalClockDisplay: some View { + Text(timeString) + .font(.system(size: KioskConstants.UI.minimalClockFontSize, weight: .ultraLight, design: .default)) + .foregroundColor(.white.opacity(0.9)) + .monospacedDigit() + .accessibilityLabel(L10n.Kiosk.Clock.Accessibility.currentTime(accessibleTimeString)) + } + + private var digitalClockDisplay: some View { + Text(timeString) + .font(.system(size: KioskConstants.UI.digitalClockFontSize, weight: .medium, design: .monospaced)) + .foregroundColor(.green) + .monospacedDigit() + .accessibilityLabel(L10n.Kiosk.Clock.Accessibility.currentTime(accessibleTimeString)) + } + + private var analogClockDisplay: some View { + KioskAnalogClockView(date: currentTime) + .frame( + width: KioskConstants.UI.analogClockSize, + height: KioskConstants.UI.analogClockSize + ) + .accessibilityLabel(L10n.Kiosk.Clock.Accessibility.analogClock(accessibleTimeString)) + } + + private var timeString: String { + let use24Hour = manager.settings.clockUse24HourFormat + let showSeconds = manager.settings.clockShowSeconds + + let formatter: DateFormatter + if use24Hour { + formatter = showSeconds ? KioskDateFormatters.time24hSeconds : KioskDateFormatters.time24h + } else { + formatter = showSeconds ? KioskDateFormatters.time12hSeconds : KioskDateFormatters.time12h + } + return formatter.string(from: currentTime) + } + + private var accessibleTimeString: String { + KioskDateFormatters.accessibility.string(from: currentTime) + } + + // MARK: - Date Display + + private var dateDisplay: some View { + Text(dateString) + .font(.system(size: 28, weight: .light, design: .rounded)) + .foregroundColor(.white.opacity(0.7)) + .accessibilityLabel(L10n.Kiosk.Clock.Accessibility.date(dateString)) + } + + private var dateString: String { + KioskDateFormatters.dateDisplay.string(from: currentTime) + } +} + +// MARK: - Kiosk Analog Clock View + +struct KioskAnalogClockView: View { + let date: Date + + private var calendar: Calendar { Calendar.current } + + private var hours: Int { + calendar.component(.hour, from: date) % 12 + } + + private var minutes: Int { + calendar.component(.minute, from: date) + } + + private var seconds: Int { + calendar.component(.second, from: date) + } + + var body: some View { + ZStack { + // Clock face + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 2) + .accessibilityHidden(true) + + // Hour markers + ForEach(0 ..< 12, id: \.self) { hour in + Rectangle() + .fill(Color.white.opacity(0.6)) + .frame(width: hour.isMultiple(of: 3) ? 3 : 1, height: hour.isMultiple(of: 3) ? 15 : 8) + .offset(y: -130) + .rotationEffect(.degrees(Double(hour) * 30)) + .accessibilityHidden(true) + } + + // Hour hand + Rectangle() + .fill(Color.white) + .frame(width: 4, height: 70) + .offset(y: -35) + .rotationEffect(.degrees(Double(hours) * 30 + Double(minutes) * 0.5)) + .accessibilityHidden(true) + + // Minute hand + Rectangle() + .fill(Color.white) + .frame(width: 3, height: 100) + .offset(y: -50) + .rotationEffect(.degrees(Double(minutes) * 6)) + .accessibilityHidden(true) + + // Second hand + Rectangle() + .fill(Color.red) + .frame(width: 1, height: 110) + .offset(y: -55) + .rotationEffect(.degrees(Double(seconds) * 6)) + .accessibilityHidden(true) + + // Center dot + Circle() + .fill(Color.white) + .frame(width: 10, height: 10) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + } +} + +// MARK: - Preview + +#Preview("Large Clock") { + KioskClockScreensaverView() + .preferredColorScheme(.dark) +} diff --git a/Sources/App/Kiosk/Screensaver/KioskScreensaverViewController.swift b/Sources/App/Kiosk/Screensaver/KioskScreensaverViewController.swift new file mode 100644 index 000000000..e28dd601a --- /dev/null +++ b/Sources/App/Kiosk/Screensaver/KioskScreensaverViewController.swift @@ -0,0 +1,229 @@ +import Shared +import SwiftUI +import UIKit + +// MARK: - Kiosk Screensaver View Controller + +/// Main view controller that hosts and manages screensaver views +/// Supports screensaver modes: blank, dim, clock +public final class KioskScreensaverViewController: UIViewController, UIGestureRecognizerDelegate, KioskModeObserver { + // MARK: - Properties + + private var currentMode: ScreensaverMode? + private var hostingController: UIHostingController? + private var secretExitGestureController: KioskSecretExitGestureViewController? + private var pixelShiftOffset: CGPoint = .zero + private var wakeGesture: UITapGestureRecognizer? + + /// Callback when secret exit gesture wants to show settings + public var onShowSettings: (() -> Void)? + + // MARK: - Lifecycle + + override public func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + view.isUserInteractionEnabled = true + + // Add tap gesture to wake screen + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + tapGesture.delegate = self + wakeGesture = tapGesture + view.addGestureRecognizer(tapGesture) + + // Add swipe gesture for additional wake trigger + let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe)) + swipeGesture.direction = [.up, .down, .left, .right] + view.addGestureRecognizer(swipeGesture) + + setupObservers() + setupSecretExitGesture() + } + + // MARK: - Gesture Recognizer Delegate + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + // Don't let the wake gesture intercept taps in the secret exit corner + guard gestureRecognizer === wakeGesture else { return true } + + let settings = KioskModeManager.shared.settings + guard settings.secretExitGestureEnabled, KioskModeManager.shared.isKioskModeActive else { + return true + } + + let location = touch.location(in: view) + let cornerSize: CGFloat = 80 + let corner = settings.secretExitGestureCorner + + let cornerRect: CGRect + switch corner { + case .topLeft: + cornerRect = CGRect(x: 0, y: 0, width: cornerSize, height: cornerSize) + case .topRight: + cornerRect = CGRect(x: view.bounds.width - cornerSize, y: 0, width: cornerSize, height: cornerSize) + case .bottomLeft: + cornerRect = CGRect(x: 0, y: view.bounds.height - cornerSize, width: cornerSize, height: cornerSize) + case .bottomRight: + cornerRect = CGRect( + x: view.bounds.width - cornerSize, + y: view.bounds.height - cornerSize, + width: cornerSize, + height: cornerSize + ) + } + + // If touch is in the corner, don't let wake gesture receive it + return !cornerRect.contains(location) + } + + // MARK: - Secret Exit Gesture + + private func setupSecretExitGesture() { + let controller = KioskSecretExitGestureViewController() + secretExitGestureController = controller + + // Forward the callback + controller.onShowSettings = { [weak self] in + self?.onShowSettings?() + } + + addChild(controller) + view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: view.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + controller.didMove(toParent: self) + + // Bring to front so it can receive taps + view.bringSubviewToFront(controller.view) + } + + override public var prefersStatusBarHidden: Bool { + true + } + + override public var prefersHomeIndicatorAutoHidden: Bool { + true + } + + // MARK: - Public Methods + + /// Configure the screensaver content for the specified mode + /// Call before presenting — the modal's crossDissolve transition handles fade-in + public func configure(mode: ScreensaverMode) { + guard currentMode != mode else { return } + + Current.Log.info("Configuring screensaver: \(mode.rawValue)") + currentMode = mode + + // Remove existing content + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil + + // Configure view based on mode + switch mode { + case .blank: + showBlankScreensaver() + + case .dim: + showDimScreensaver() + + case .clock: + showClockScreensaver() + } + } + + /// Apply pixel shift offset + public func applyPixelShift() { + let manager = KioskModeManager.shared + guard manager.settings.pixelShiftEnabled else { return } + + let amount = manager.settings.pixelShiftAmount + + // Random offset within range + let newOffset = CGPoint( + x: CGFloat.random(in: -amount ... amount), + y: CGFloat.random(in: -amount ... amount) + ) + + pixelShiftOffset = newOffset + + // Apply transform to hosting controller view + UIView.animate(withDuration: KioskConstants.Animation.pixelShift) { + self.hostingController?.view.transform = CGAffineTransform( + translationX: newOffset.x, + y: newOffset.y + ) + } + } + + // MARK: - Private Methods + + private func setupObservers() { + KioskModeManager.shared.addObserver(self) + } + + // MARK: - KioskModeObserver + + public func kioskPixelShiftDidTrigger(amount: CGFloat) { + applyPixelShift() + } + + @objc private func handleTap() { + KioskModeManager.shared.wakeScreen(source: "touch") + } + + @objc private func handleSwipe() { + KioskModeManager.shared.wakeScreen(source: "swipe") + } + + // MARK: - Screensaver Mode Views + + private func showBlankScreensaver() { + // Just black screen - view.backgroundColor is already black + view.backgroundColor = .black + } + + private func showDimScreensaver() { + // Dim overlay on top of existing content + // The actual dimming is handled by brightness control in KioskModeManager + view.backgroundColor = UIColor.black.withAlphaComponent(0.7) + } + + private func showClockScreensaver() { + let clockView = KioskClockScreensaverView() + embedSwiftUIView(AnyView(clockView)) + } + + private func embedSwiftUIView(_ content: AnyView) { + let hosting = UIHostingController(rootView: content) + hosting.view.backgroundColor = .clear + + addChild(hosting) + view.addSubview(hosting.view) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + hosting.didMove(toParent: self) + hostingController = hosting + + // Bring secret exit gesture back to front so it can receive taps + if let secretView = secretExitGestureController?.view { + view.bringSubviewToFront(secretView) + } + } +} diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift new file mode 100644 index 000000000..7d1360bcb --- /dev/null +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -0,0 +1,273 @@ +import SFSafeSymbols +import Shared +import SwiftUI + +// MARK: - Main Kiosk Settings View + +/// Kiosk settings view +public struct KioskSettingsView: View { + @StateObject private var viewModel: KioskSettingsViewModel + @Environment(\.dismiss) private var environmentDismiss + + /// Initialize with optional explicit dismiss closure + /// - Parameter onDismiss: Closure called when the view should be dismissed. + /// If nil, uses SwiftUI's environment dismiss (for NavigationLink contexts). + /// Pass explicit closure when presenting via UIKit's UINavigationController. + public init(onDismiss: (() -> Void)? = nil) { + _viewModel = StateObject(wrappedValue: KioskSettingsViewModel(onDismiss: onDismiss)) + } + + public var body: some View { + Form { + kioskModeSection + coreSettingsSection + brightnessSection + screensaverSection + } + .navigationTitle(L10n.Kiosk.title) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.doneLabel) { + viewModel.dismiss(using: environmentDismiss) + } + } + } + .disabled(viewModel.authRequired && !viewModel.isAuthenticated) + .overlay { authGateOverlay } + .onAppear { + viewModel.onAppear() + } + .onDisappear { + viewModel.onDisappear() + } + .onChange(of: viewModel.settings) { _ in + viewModel.settingsChanged() + } + .alert(L10n.Kiosk.AuthError.title, isPresented: $viewModel.showingAuthError) { + Button(L10n.okLabel, role: .cancel) { + viewModel.handleAuthErrorDismissed(using: environmentDismiss) + } + } message: { + Text(viewModel.authErrorMessage) + } + } + + // MARK: - Auth Gate Overlay + + @ViewBuilder private var authGateOverlay: some View { + if viewModel.authRequired, !viewModel.isAuthenticated { + VStack(spacing: DesignSystem.Spaces.three) { + Spacer() + + Image(systemSymbol: .lockFill) + .font(.system(size: 64)) + .foregroundColor(.secondary) + + Text(L10n.Kiosk.Auth.gateTitle) + .font(DesignSystem.Font.largeTitle.bold()) + .multilineTextAlignment(.center) + + Text(L10n.Kiosk.Auth.gateDescription) + .font(DesignSystem.Font.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, DesignSystem.Spaces.two) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .bottom) { + VStack(spacing: DesignSystem.Spaces.one) { + Button { + viewModel.authenticateForSettings() + } label: { + Text(L10n.Kiosk.Auth.authenticateButton) + } + .buttonStyle(.primaryButton) + + Button { + viewModel.dismiss(using: environmentDismiss) + } label: { + Text(L10n.Kiosk.Auth.goBackButton) + } + .buttonStyle(.secondaryButton) + .tint(Color.haPrimary) + } + .padding([.horizontal, .top], DesignSystem.Spaces.two) + .background(Color(uiColor: .systemBackground).opacity(0.95)) + } + .background(Color(uiColor: .systemBackground)) + } + } + + // MARK: - Kiosk Mode Section + + private var kioskModeSection: some View { + Section { + Toggle(isOn: Binding( + get: { viewModel.isKioskModeActive }, + set: { newValue in + if newValue { + viewModel.enableKioskMode() + } else { + viewModel.attemptKioskExit() + } + } + )) { + Label(L10n.Kiosk.enableButton, systemSymbol: .lock) + } + } header: { + Text(L10n.Kiosk.Section.title) + } footer: { + if !viewModel.isKioskModeActive { + Text(L10n.Kiosk.Footer.description) + } + } + } + + // MARK: - Core Settings Section + + private var coreSettingsSection: some View { + Section { + Toggle(isOn: $viewModel.settings.requireDeviceAuthentication) { + Label(L10n.Kiosk.Security.deviceAuth, systemSymbol: .lockShield) + } + + Toggle(isOn: $viewModel.settings.hideStatusBar) { + Label(L10n.Kiosk.Security.hideStatusBar, systemSymbol: .rectangleExpandVertical) + } + + Toggle(isOn: $viewModel.settings.preventAutoLock) { + Label(L10n.Kiosk.Security.preventAutolock, systemSymbol: .lockOpenDisplay) + } + + // Secret Exit Gesture + Toggle(isOn: $viewModel.settings.secretExitGestureEnabled) { + Label(L10n.Kiosk.Security.secretGesture, systemSymbol: .handTap) + } + + if viewModel.settings.secretExitGestureEnabled { + Picker(L10n.Kiosk.Security.gestureCorner, selection: $viewModel.settings.secretExitGestureCorner) { + ForEach(ScreenCorner.allCases, id: \.self) { corner in + Text(corner.displayName).tag(corner) + } + } + + Stepper( + value: $viewModel.settings.secretExitGestureTaps, + in: 2 ... 5 + ) { + Label( + L10n.Kiosk.Security.tapsRequired(viewModel.settings.secretExitGestureTaps), + systemSymbol: .number + ) + } + } + } header: { + Text(L10n.Kiosk.Security.section) + } footer: { + if viewModel.settings.secretExitGestureEnabled { + Text( + L10n.Kiosk.Security.gestureFooter( + viewModel.settings.secretExitGestureCorner.displayName, + viewModel.settings.secretExitGestureTaps + ) + ) + } + } + } + + // MARK: - Brightness Section + + private var brightnessSection: some View { + Section { + Toggle(isOn: $viewModel.settings.brightnessControlEnabled) { + Label(L10n.Kiosk.Brightness.control, systemSymbol: .sunMax) + } + + if viewModel.settings.brightnessControlEnabled { + VStack(alignment: .leading) { + Text(L10n.Kiosk.Brightness.manual(Int(viewModel.settings.manualBrightness * 100))) + .font(.caption) + Slider(value: $viewModel.settings.manualBrightness, in: 0.1 ... 1.0, step: 0.05) + } + } + } header: { + Text(L10n.Kiosk.Brightness.section) + } + } + + // MARK: - Screensaver Section + + private var screensaverSection: some View { + Section { + Toggle(isOn: $viewModel.settings.screensaverEnabled) { + Label(L10n.Kiosk.Screensaver.toggle, systemSymbol: .moonStars) + } + + if viewModel.settings.screensaverEnabled { + Picker(L10n.Kiosk.Screensaver.mode, selection: $viewModel.settings.screensaverMode) { + Text(L10n.Kiosk.Screensaver.Mode.clock).tag(ScreensaverMode.clock) + Text(L10n.Kiosk.Screensaver.Mode.dim).tag(ScreensaverMode.dim) + Text(L10n.Kiosk.Screensaver.Mode.blank).tag(ScreensaverMode.blank) + } + + HStack { + Text(L10n.Kiosk.Screensaver.timeout) + Spacer() + Picker("", selection: Binding( + get: { ScreensaverTimeout(from: viewModel.settings.screensaverTimeout) }, + set: { viewModel.settings.screensaverTimeout = $0.timeInterval } + )) { + ForEach(ScreensaverTimeout.allCases, id: \.self) { timeout in + Text(timeout.displayName).tag(timeout) + } + } + .labelsHidden() + } + + VStack(alignment: .leading) { + Text(L10n.Kiosk.Screensaver.dimLevel(Int(viewModel.settings.screensaverDimLevel * 100))) + .font(.caption) + Slider(value: $viewModel.settings.screensaverDimLevel, in: 0.01 ... 0.5, step: 0.01) + } + + Toggle(isOn: $viewModel.settings.pixelShiftEnabled) { + Label(L10n.Kiosk.Screensaver.pixelShift, systemSymbol: .arrowLeftArrowRight) + } + + if viewModel.settings.screensaverMode == .clock { + Picker(L10n.Kiosk.Clock.style, selection: $viewModel.settings.clockStyle) { + ForEach(ClockStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + + Toggle(isOn: $viewModel.settings.clockShowDate) { + Label(L10n.Kiosk.Clock.showDate, systemSymbol: .calendar) + } + + Toggle(isOn: $viewModel.settings.clockShowSeconds) { + Label(L10n.Kiosk.Clock.showSeconds, systemSymbol: .clock) + } + + Toggle(isOn: $viewModel.settings.clockUse24HourFormat) { + Label(L10n.Kiosk.Clock._24hour, systemSymbol: .clock) + } + } + } + } header: { + Text(L10n.Kiosk.Screensaver.section) + } footer: { + Text(L10n.Kiosk.Screensaver.pixelShiftFooter) + } + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + KioskSettingsView() + } +} diff --git a/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift b/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift new file mode 100644 index 000000000..a3c0cfb7c --- /dev/null +++ b/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift @@ -0,0 +1,134 @@ +import LocalAuthentication +import Shared +import SwiftUI + +@MainActor +public final class KioskSettingsViewModel: ObservableObject { + @Published public var settings: KioskSettings + @Published public var isAuthenticated = false + @Published public var showingAuthError = false + @Published public var authErrorMessage = "" + + private let manager: KioskModeManager + private let onDismiss: (() -> Void)? + + /// Whether authentication is required to access settings + var authRequired: Bool { + manager.isKioskModeActive && manager.settings.requireDeviceAuthentication + } + + var isKioskModeActive: Bool { + manager.isKioskModeActive + } + + init(manager: KioskModeManager = .shared, onDismiss: (() -> Void)? = nil) { + self.manager = manager + self.onDismiss = onDismiss + self.settings = manager.settings + } + + func onAppear() { + manager.pauseIdleTimer() + if authRequired { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.authenticateForSettings() + } + } else { + isAuthenticated = true + } + } + + func onDisappear() { + manager.resumeIdleTimer() + } + + func dismiss(using environmentDismiss: DismissAction) { + if let onDismiss { + onDismiss() + } else { + environmentDismiss() + } + } + + func settingsChanged() { + if isAuthenticated || !authRequired { + manager.updateSettings(settings) + } + } + + func enableKioskMode() { + manager.enableKioskMode() + } + + func attemptKioskExit() { + let authSettings = manager.settings + guard authSettings.requireDeviceAuthentication else { + manager.disableKioskMode() + return + } + + let context = LAContext() + var error: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + manager.disableKioskMode() + return + } + + let reason = L10n.Kiosk.AuthError.reason + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in + DispatchQueue.main.async { [weak self] in + if success { + self?.manager.disableKioskMode() + } else if let authError = authError as? LAError { + switch authError.code { + case .userCancel, .appCancel: + break + default: + self?.authErrorMessage = authError.localizedDescription + self?.showingAuthError = true + } + } + } + } + } + + func authenticateForSettings() { + let context = LAContext() + var error: NSError? + let authSettings = manager.settings + + guard authSettings.requireDeviceAuthentication else { + isAuthenticated = true + return + } + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + isAuthenticated = true + return + } + + let reason = L10n.Kiosk.AuthError.reason + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in + DispatchQueue.main.async { [weak self] in + if success { + self?.isAuthenticated = true + } else if let authError = authError as? LAError { + switch authError.code { + case .userCancel, .appCancel: + break + default: + self?.authErrorMessage = authError.localizedDescription + self?.showingAuthError = true + } + } + } + } + } + + func handleAuthErrorDismissed(using environmentDismiss: DismissAction) { + if manager.isKioskModeActive { + dismiss(using: environmentDismiss) + } + } +} diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index c3218d9a8..f1f67e9e2 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1537,4 +1537,74 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "widgets.todo_list.refresh_title" = "Refresh To-do List"; "widgets.todo_list.select_list" = "Edit widget to select list."; "widgets.todo_list.title" = "To-do List"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; +"assist.settings.section.experimental.title" = "Experimental"; +"assist.settings.on_device_stt.title" = "On-device STT"; +"assist.settings.on_device_stt.language" = "Language"; + +"kiosk.title" = "Kiosk Mode"; +"kiosk.active.title" = "Kiosk Mode Active"; +"kiosk.screen_label" = "Screen: %@"; +"kiosk.screensaver_label" = "Screensaver: %@"; +"kiosk.exit_button" = "Exit Kiosk Mode"; +"kiosk.exit_hint" = "Double-tap to exit kiosk mode. Authentication may be required."; +"kiosk.enable_button" = "Enable Kiosk Mode"; +"kiosk.section.title" = "Kiosk Mode"; +"kiosk.footer.description" = "When enabled, the display will be locked to the dashboard. Use Face ID, Touch ID, or device passcode to exit."; +"kiosk.auth_error.title" = "Authentication Error"; +"kiosk.auth_error.reason" = "Authenticate to exit kiosk mode"; +"kiosk.auth.required" = "Authentication Required"; +"kiosk.auth.try_again" = "Try Again"; +"kiosk.auth.gate_title" = "Kiosk Mode Active"; +"kiosk.auth.gate_description" = "Authentication is required to access kiosk settings. Verify your identity to continue."; +"kiosk.auth.authenticate_button" = "Authenticate"; +"kiosk.auth.go_back_button" = "Go Back"; + +"kiosk.security.section" = "Security & Display"; +"kiosk.security.device_auth" = "Device Authentication"; +"kiosk.security.hide_status_bar" = "Hide Status Bar"; +"kiosk.security.prevent_autolock" = "Prevent Auto-Lock"; +"kiosk.security.secret_gesture" = "Secret Exit Gesture"; +"kiosk.security.gesture_corner" = "Exit Gesture Corner"; +"kiosk.security.taps_required" = "Taps Required: %d"; +"kiosk.security.gesture_footer" = "Tap the %@ corner %d times to access kiosk settings when locked."; + +"kiosk.brightness.section" = "Brightness"; +"kiosk.brightness.control" = "Brightness Control"; +"kiosk.brightness.manual" = "Manual Brightness: %d%%"; + +"kiosk.screensaver.section" = "Screensaver"; +"kiosk.screensaver.toggle" = "Screensaver"; +"kiosk.screensaver.mode" = "Mode"; +"kiosk.screensaver.mode.clock" = "Clock"; +"kiosk.screensaver.mode.dim" = "Dim"; +"kiosk.screensaver.mode.blank" = "Blank"; +"kiosk.screensaver.timeout" = "Timeout"; +"kiosk.screensaver.timeout.30sec" = "30 seconds"; +"kiosk.screensaver.timeout.1min" = "1 minute"; +"kiosk.screensaver.timeout.2min" = "2 minutes"; +"kiosk.screensaver.timeout.5min" = "5 minutes"; +"kiosk.screensaver.timeout.10min" = "10 minutes"; +"kiosk.screensaver.timeout.15min" = "15 minutes"; +"kiosk.screensaver.timeout.30min" = "30 minutes"; +"kiosk.screensaver.dim_level" = "Dim Level: %d%%"; +"kiosk.screensaver.pixel_shift" = "Pixel Shift (OLED)"; +"kiosk.screensaver.pixel_shift_footer" = "Pixel shift helps prevent burn-in on OLED displays by slightly moving content periodically."; + +"kiosk.clock.section" = "Clock Display"; +"kiosk.clock.style" = "Clock Style"; +"kiosk.clock.style.large" = "Large"; +"kiosk.clock.style.minimal" = "Minimal"; +"kiosk.clock.style.analog" = "Analog"; +"kiosk.clock.style.digital" = "Digital"; +"kiosk.clock.show_date" = "Show Date"; +"kiosk.clock.show_seconds" = "Show Seconds"; +"kiosk.clock.24hour" = "24-Hour Format"; +"kiosk.clock.accessibility.current_time" = "Current time: %@"; +"kiosk.clock.accessibility.analog_clock" = "Analog clock showing %@"; +"kiosk.clock.accessibility.date" = "Date: %@"; + +"kiosk.corner.top_left" = "Top Left"; +"kiosk.corner.top_right" = "Top Right"; +"kiosk.corner.bottom_left" = "Bottom Left"; +"kiosk.corner.bottom_right" = "Bottom Right"; diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index 895085ed1..9525161ae 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -5,6 +5,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case servers case general case gestures + case kiosk case location case notifications case sensors @@ -24,6 +25,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .servers: return L10n.Settings.ConnectionSection.servers case .general: return L10n.SettingsDetails.General.title case .gestures: return L10n.Gestures.Screen.title + case .kiosk: return L10n.Kiosk.title case .location: return L10n.Settings.DetailsSection.LocationSettingsRow.title case .notifications: return L10n.Settings.DetailsSection.NotificationSettingsRow.title case .sensors: return L10n.SettingsSensors.title @@ -49,6 +51,8 @@ enum SettingsItem: String, Hashable, CaseIterable { MaterialDesignIconsImage(icon: .paletteOutlineIcon, size: 24) case .gestures: MaterialDesignIconsImage(icon: .gestureIcon, size: 24) + case .kiosk: + MaterialDesignIconsImage(icon: .tabletIcon, size: 24) case .location: MaterialDesignIconsImage(icon: .crosshairsGpsIcon, size: 24) case .notifications: @@ -97,6 +101,8 @@ enum SettingsItem: String, Hashable, CaseIterable { GeneralSettingsView() case .gestures: GesturesSetupView() + case .kiosk: + KioskSettingsView() case .location: SettingsLocationView() case .notifications: @@ -131,18 +137,27 @@ enum SettingsItem: String, Hashable, CaseIterable { allCases.filter { item in // Filter based on platform #if targetEnvironment(macCatalyst) - if item == .servers || item == .gestures || item == .watch || item == .carPlay || + if item == .servers || item == .gestures || item == .kiosk || item == .watch || item == .carPlay || item == .complications || item == .nfc || item == .help || item == .whatsNew { return false } #endif + // Kiosk mode is in beta — only show in TestFlight/debug builds + if item == .kiosk, !Current.isTestFlight { + return false + } return true } } static var generalItems: [SettingsItem] { - [.general, .gestures, .location, .notifications] + [.general, .gestures, .kiosk, .location, .notifications].filter { item in + if item == .kiosk, !Current.isTestFlight { + return false + } + return true + } } static var integrationItems: [SettingsItem] { diff --git a/Sources/App/Settings/Settings/SettingsView.swift b/Sources/App/Settings/Settings/SettingsView.swift index 0c5365f1c..ea63fbe4f 100644 --- a/Sources/App/Settings/Settings/SettingsView.swift +++ b/Sources/App/Settings/Settings/SettingsView.swift @@ -244,7 +244,18 @@ struct SettingsView: View { private func settingsItemLabel(_ item: SettingsItem) -> some View { Label { - Text(item.title) + HStack(spacing: 8) { + Text(item.title) + if item == .kiosk { + Text("BETA") + .font(.caption2.bold()) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange) + .clipShape(Capsule()) + } + } } icon: { item.icon } diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 9dbfefc17..3da40e92d 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -14,6 +14,7 @@ public enum GRDBDatabaseTable: String { case homeViewConfiguration case cameraListConfiguration case assistConfiguration + case kioskSettings // Dropped since 2025.2, now saved as json file // Context: https://github.com/groue/GRDB.swift/issues/1626#issuecomment-2623927815 @@ -175,4 +176,10 @@ public enum DatabaseTables { case enableOnDeviceTTS case onDeviceTTSVoiceIdentifier } + + // Kiosk mode settings (stored as JSON blob) + public enum KioskSettings: String, CaseIterable { + case id + case settingsJSON + } } diff --git a/Sources/Shared/Database/GRDB+Initialization.swift b/Sources/Shared/Database/GRDB+Initialization.swift index 4d83b4ba5..05cf340ce 100644 --- a/Sources/Shared/Database/GRDB+Initialization.swift +++ b/Sources/Shared/Database/GRDB+Initialization.swift @@ -57,6 +57,7 @@ public extension DatabaseQueue { HomeViewConfigurationTable(), CameraListConfigurationTable(), AssistConfigurationTable(), + KioskSettingsTable(), ] } diff --git a/Sources/Shared/Database/Tables/KioskSettingsTable.swift b/Sources/Shared/Database/Tables/KioskSettingsTable.swift new file mode 100644 index 000000000..a6ab91b03 --- /dev/null +++ b/Sources/Shared/Database/Tables/KioskSettingsTable.swift @@ -0,0 +1,24 @@ +import Foundation +import GRDB + +final class KioskSettingsTable: DatabaseTableProtocol { + var tableName: String { GRDBDatabaseTable.kioskSettings.rawValue } + + var definedColumns: [String] { DatabaseTables.KioskSettings.allCases.map(\.rawValue) } + + func createIfNeeded(database: DatabaseQueue) throws { + let shouldCreateTable = try database.read { db in + try !db.tableExists(tableName) + } + if shouldCreateTable { + try database.write { db in + try db.create(table: tableName) { t in + t.primaryKey(DatabaseTables.KioskSettings.id.rawValue, .text).notNull() + t.column(DatabaseTables.KioskSettings.settingsJSON.rawValue, .jsonText).notNull() + } + } + } else { + try migrateColumns(database: database) + } + } +} diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index e5100a47b..5fa503853 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -141,6 +141,10 @@ public class AppEnvironment { /// Wrapper around UIApplication for use in shared framework public var application: (() -> UIApplication)? + /// Wrapper around UIScreen.main.brightness for testability + public var screenBrightness: () -> CGFloat = { UIScreen.main.brightness } + public var setScreenBrightness: (CGFloat) -> Void = { UIScreen.main.brightness = $0 } + public lazy var isForegroundApp = { self.application?().applicationState == .active } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index e9a2f12a1..12e40cfb9 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1676,6 +1676,177 @@ public enum L10n { public static var serverRequiredForValue: String { return L10n.tr("Localizable", "intents.server_required_for_value") } } + public enum Kiosk { + /// Enable Kiosk Mode + public static var enableButton: String { return L10n.tr("Localizable", "kiosk.enable_button") } + /// Exit Kiosk Mode + public static var exitButton: String { return L10n.tr("Localizable", "kiosk.exit_button") } + /// Double-tap to exit kiosk mode. Authentication may be required. + public static var exitHint: String { return L10n.tr("Localizable", "kiosk.exit_hint") } + /// Screen: %@ + public static func screenLabel(_ p1: Any) -> String { + return L10n.tr("Localizable", "kiosk.screen_label", String(describing: p1)) + } + /// Screensaver: %@ + public static func screensaverLabel(_ p1: Any) -> String { + return L10n.tr("Localizable", "kiosk.screensaver_label", String(describing: p1)) + } + /// Kiosk Mode + public static var title: String { return L10n.tr("Localizable", "kiosk.title") } + public enum Active { + /// Kiosk Mode Active + public static var title: String { return L10n.tr("Localizable", "kiosk.active.title") } + } + public enum Auth { + /// Authenticate + public static var authenticateButton: String { return L10n.tr("Localizable", "kiosk.auth.authenticate_button") } + /// Authentication is required to access kiosk settings. Verify your identity to continue. + public static var gateDescription: String { return L10n.tr("Localizable", "kiosk.auth.gate_description") } + /// Kiosk Mode Active + public static var gateTitle: String { return L10n.tr("Localizable", "kiosk.auth.gate_title") } + /// Go Back + public static var goBackButton: String { return L10n.tr("Localizable", "kiosk.auth.go_back_button") } + /// Authentication Required + public static var `required`: String { return L10n.tr("Localizable", "kiosk.auth.required") } + /// Try Again + public static var tryAgain: String { return L10n.tr("Localizable", "kiosk.auth.try_again") } + } + public enum AuthError { + /// Authenticate to exit kiosk mode + public static var reason: String { return L10n.tr("Localizable", "kiosk.auth_error.reason") } + /// Authentication Error + public static var title: String { return L10n.tr("Localizable", "kiosk.auth_error.title") } + } + public enum Brightness { + /// Brightness Control + public static var control: String { return L10n.tr("Localizable", "kiosk.brightness.control") } + /// Manual Brightness: %d%% + public static func manual(_ p1: Int) -> String { + return L10n.tr("Localizable", "kiosk.brightness.manual", p1) + } + /// Brightness + public static var section: String { return L10n.tr("Localizable", "kiosk.brightness.section") } + } + public enum Clock { + /// 24-Hour Format + public static var _24hour: String { return L10n.tr("Localizable", "kiosk.clock.24hour") } + /// Clock Display + public static var section: String { return L10n.tr("Localizable", "kiosk.clock.section") } + /// Show Date + public static var showDate: String { return L10n.tr("Localizable", "kiosk.clock.show_date") } + /// Show Seconds + public static var showSeconds: String { return L10n.tr("Localizable", "kiosk.clock.show_seconds") } + /// Clock Style + public static var style: String { return L10n.tr("Localizable", "kiosk.clock.style") } + public enum Accessibility { + /// Analog clock showing %@ + public static func analogClock(_ p1: Any) -> String { + return L10n.tr("Localizable", "kiosk.clock.accessibility.analog_clock", String(describing: p1)) + } + /// Current time: %@ + public static func currentTime(_ p1: Any) -> String { + return L10n.tr("Localizable", "kiosk.clock.accessibility.current_time", String(describing: p1)) + } + /// Date: %@ + public static func date(_ p1: Any) -> String { + return L10n.tr("Localizable", "kiosk.clock.accessibility.date", String(describing: p1)) + } + } + public enum Style { + /// Analog + public static var analog: String { return L10n.tr("Localizable", "kiosk.clock.style.analog") } + /// Digital + public static var digital: String { return L10n.tr("Localizable", "kiosk.clock.style.digital") } + /// Large + public static var large: String { return L10n.tr("Localizable", "kiosk.clock.style.large") } + /// Minimal + public static var minimal: String { return L10n.tr("Localizable", "kiosk.clock.style.minimal") } + } + } + public enum Corner { + /// Bottom Left + public static var bottomLeft: String { return L10n.tr("Localizable", "kiosk.corner.bottom_left") } + /// Bottom Right + public static var bottomRight: String { return L10n.tr("Localizable", "kiosk.corner.bottom_right") } + /// Top Left + public static var topLeft: String { return L10n.tr("Localizable", "kiosk.corner.top_left") } + /// Top Right + public static var topRight: String { return L10n.tr("Localizable", "kiosk.corner.top_right") } + } + public enum Footer { + /// When enabled, the display will be locked to the dashboard. Use Face ID, Touch ID, or device passcode to exit. + public static var description: String { return L10n.tr("Localizable", "kiosk.footer.description") } + } + public enum Screensaver { + /// Dim Level: %d%% + public static func dimLevel(_ p1: Int) -> String { + return L10n.tr("Localizable", "kiosk.screensaver.dim_level", p1) + } + /// Mode + public static var mode: String { return L10n.tr("Localizable", "kiosk.screensaver.mode") } + /// Pixel Shift (OLED) + public static var pixelShift: String { return L10n.tr("Localizable", "kiosk.screensaver.pixel_shift") } + /// Pixel shift helps prevent burn-in on OLED displays by slightly moving content periodically. + public static var pixelShiftFooter: String { return L10n.tr("Localizable", "kiosk.screensaver.pixel_shift_footer") } + /// Screensaver + public static var section: String { return L10n.tr("Localizable", "kiosk.screensaver.section") } + /// Timeout + public static var timeout: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout") } + /// Screensaver + public static var toggle: String { return L10n.tr("Localizable", "kiosk.screensaver.toggle") } + public enum Mode { + /// Blank + public static var blank: String { return L10n.tr("Localizable", "kiosk.screensaver.mode.blank") } + /// Clock + public static var clock: String { return L10n.tr("Localizable", "kiosk.screensaver.mode.clock") } + /// Dim + public static var dim: String { return L10n.tr("Localizable", "kiosk.screensaver.mode.dim") } + } + public enum Timeout { + /// 10 minutes + public static var _10min: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout.10min") } + /// 15 minutes + public static var _15min: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout.15min") } + /// 1 minute + public static var _1min: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout.1min") } + /// 2 minutes + public static var _2min: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout.2min") } + /// 30 minutes + public static var _30min: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout.30min") } + /// 30 seconds + public static var _30sec: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout.30sec") } + /// 5 minutes + public static var _5min: String { return L10n.tr("Localizable", "kiosk.screensaver.timeout.5min") } + } + } + public enum Section { + /// Kiosk Mode + public static var title: String { return L10n.tr("Localizable", "kiosk.section.title") } + } + public enum Security { + /// Device Authentication + public static var deviceAuth: String { return L10n.tr("Localizable", "kiosk.security.device_auth") } + /// Exit Gesture Corner + public static var gestureCorner: String { return L10n.tr("Localizable", "kiosk.security.gesture_corner") } + /// Tap the %@ corner %d times to access kiosk settings when locked. + public static func gestureFooter(_ p1: Any, _ p2: Int) -> String { + return L10n.tr("Localizable", "kiosk.security.gesture_footer", String(describing: p1), p2) + } + /// Hide Status Bar + public static var hideStatusBar: String { return L10n.tr("Localizable", "kiosk.security.hide_status_bar") } + /// Prevent Auto-Lock + public static var preventAutolock: String { return L10n.tr("Localizable", "kiosk.security.prevent_autolock") } + /// Secret Exit Gesture + public static var secretGesture: String { return L10n.tr("Localizable", "kiosk.security.secret_gesture") } + /// Security & Display + public static var section: String { return L10n.tr("Localizable", "kiosk.security.section") } + /// Taps Required: %d + public static func tapsRequired(_ p1: Int) -> String { + return L10n.tr("Localizable", "kiosk.security.taps_required", p1) + } + } + } + public enum LegacyActions { /// Legacy iOS Actions are not the recommended way to interact with Home Assistant anymore, please use Scripts, Scenes and Automations directly in your Widgets, Apple Watch and CarPlay. public static var disclaimer: String { return L10n.tr("Localizable", "legacy_actions.disclaimer") } diff --git a/Tests/App/Kiosk/KioskSettings.test.swift b/Tests/App/Kiosk/KioskSettings.test.swift new file mode 100644 index 000000000..6a9f7f421 --- /dev/null +++ b/Tests/App/Kiosk/KioskSettings.test.swift @@ -0,0 +1,101 @@ +import Foundation +@testable import HomeAssistant +import Shared +import Testing + +// MARK: - KioskSettings Codable Tests + +struct KioskSettingsCodableTests { + @Test func defaultSettingsRoundtrip() async throws { + let original = KioskSettings() + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(KioskSettings.self, from: encoded) + + #expect(decoded == original) + } + + @Test func customSettingsRoundtrip() async throws { + var settings = KioskSettings() + settings.isKioskModeEnabled = true + settings.requireDeviceAuthentication = true + settings.hideStatusBar = true + settings.preventAutoLock = true + settings.screensaverTimeout = 600 + settings.screensaverMode = .clock + settings.clockStyle = .analog + settings.manualBrightness = 0.9 + settings.pixelShiftAmount = 15 + settings.secretExitGestureCorner = .bottomLeft + settings.secretExitGestureTaps = 5 + + let encoded = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(KioskSettings.self, from: encoded) + + #expect(decoded == settings) + #expect(decoded.isKioskModeEnabled == true) + #expect(decoded.requireDeviceAuthentication == true) + #expect(decoded.screensaverMode == .clock) + #expect(decoded.clockStyle == .analog) + #expect(decoded.secretExitGestureCorner == .bottomLeft) + #expect(decoded.secretExitGestureTaps == 5) + } +} + +// MARK: - Enum Display Name Tests + +struct EnumDisplayNameTests { + @Test func screensaverModeDisplayNames() async throws { + #expect(ScreensaverMode.blank.displayName == L10n.Kiosk.Screensaver.Mode.blank) + #expect(ScreensaverMode.dim.displayName == L10n.Kiosk.Screensaver.Mode.dim) + #expect(ScreensaverMode.clock.displayName == L10n.Kiosk.Screensaver.Mode.clock) + } + + @Test func clockStyleDisplayNames() async throws { + #expect(ClockStyle.large.displayName == L10n.Kiosk.Clock.Style.large) + #expect(ClockStyle.minimal.displayName == L10n.Kiosk.Clock.Style.minimal) + #expect(ClockStyle.analog.displayName == L10n.Kiosk.Clock.Style.analog) + #expect(ClockStyle.digital.displayName == L10n.Kiosk.Clock.Style.digital) + } + + @Test func screenCornerDisplayNames() async throws { + #expect(ScreenCorner.topLeft.displayName == L10n.Kiosk.Corner.topLeft) + #expect(ScreenCorner.topRight.displayName == L10n.Kiosk.Corner.topRight) + #expect(ScreenCorner.bottomLeft.displayName == L10n.Kiosk.Corner.bottomLeft) + #expect(ScreenCorner.bottomRight.displayName == L10n.Kiosk.Corner.bottomRight) + } +} + +// MARK: - ScreensaverTimeout Tests + +struct ScreensaverTimeoutTests { + @Test func allCasesHaveCorrectIntervals() async throws { + #expect(ScreensaverTimeout.thirtySeconds.timeInterval == 30) + #expect(ScreensaverTimeout.oneMinute.timeInterval == 60) + #expect(ScreensaverTimeout.twoMinutes.timeInterval == 120) + #expect(ScreensaverTimeout.fiveMinutes.timeInterval == 300) + #expect(ScreensaverTimeout.tenMinutes.timeInterval == 600) + #expect(ScreensaverTimeout.fifteenMinutes.timeInterval == 900) + #expect(ScreensaverTimeout.thirtyMinutes.timeInterval == 1800) + } + + @Test func allCasesHaveDisplayNames() async throws { + for timeout in ScreensaverTimeout.allCases { + #expect(!timeout.displayName.isEmpty) + } + } + + @Test func caseCount() async throws { + #expect(ScreensaverTimeout.allCases.count == 7) + } + + @Test func initFromValidInterval() async throws { + #expect(ScreensaverTimeout(from: 30) == .thirtySeconds) + #expect(ScreensaverTimeout(from: 300) == .fiveMinutes) + #expect(ScreensaverTimeout(from: 1800) == .thirtyMinutes) + } + + @Test func initFromInvalidIntervalDefaultsToFiveMinutes() async throws { + #expect(ScreensaverTimeout(from: 999) == .fiveMinutes) + #expect(ScreensaverTimeout(from: 0) == .fiveMinutes) + } +} diff --git a/Tests/Shared/Database/DatabaseMigration.test.swift b/Tests/Shared/Database/DatabaseMigration.test.swift index 0b816f2ee..bd2b5cd97 100644 --- a/Tests/Shared/Database/DatabaseMigration.test.swift +++ b/Tests/Shared/Database/DatabaseMigration.test.swift @@ -34,7 +34,7 @@ struct DatabaseMigrationTests { } @Test("Add new columns to existing table") - func testAddNewColumns() throws { + func addNewColumns() throws { let database = try DatabaseQueue(path: ":memory:") // Create initial table with 2 columns @@ -64,7 +64,7 @@ struct DatabaseMigrationTests { } @Test("Skip already existing columns") - func testSkipExistingColumns() throws { + func skipExistingColumns() throws { let database = try DatabaseQueue(path: ":memory:") // Create table with 2 columns @@ -84,7 +84,7 @@ struct DatabaseMigrationTests { } @Test("Remove obsolete columns") - func testRemoveObsoleteColumns() throws { + func removeObsoleteColumns() throws { let database = try DatabaseQueue(path: ":memory:") // Create initial table with 3 columns @@ -116,7 +116,7 @@ struct DatabaseMigrationTests { } @Test("Handle add and remove simultaneously") - func testAddAndRemoveSimultaneously() throws { + func addAndRemoveSimultaneously() throws { let database = try DatabaseQueue(path: ":memory:") // Create initial table with columns: column1, column2, obsoleteColumn @@ -142,7 +142,7 @@ struct DatabaseMigrationTests { } @Test("Handle no changes needed") - func testNoChangesNeeded() throws { + func noChangesNeeded() throws { let database = try DatabaseQueue(path: ":memory:") // Create table with 2 columns @@ -165,7 +165,7 @@ struct DatabaseMigrationTests { } @Test("Migration with actual table: HAppEntityTable") - func testRealTableMigration() throws { + func realTableMigration() throws { let database = try DatabaseQueue(path: ":memory:") // Create HAppEntityTable diff --git a/Tests/Shared/Database/DatabaseTableProtocol.test.swift b/Tests/Shared/Database/DatabaseTableProtocol.test.swift index 180f0b74f..4321c30bf 100644 --- a/Tests/Shared/Database/DatabaseTableProtocol.test.swift +++ b/Tests/Shared/Database/DatabaseTableProtocol.test.swift @@ -5,7 +5,7 @@ import Testing @Suite("Database Table Protocol Tests") struct DatabaseTableProtocolTests { @Test("HAppEntityTable conforms to DatabaseTableProtocol") - func testHAppEntityTableConformance() throws { + func hAppEntityTableConformance() throws { let table = HAppEntityTable() #expect(table.tableName == GRDBDatabaseTable.HAAppEntity.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -16,7 +16,7 @@ struct DatabaseTableProtocolTests { } @Test("WatchConfigTable conforms to DatabaseTableProtocol") - func testWatchConfigTableConformance() throws { + func watchConfigTableConformance() throws { let table = WatchConfigTable() #expect(table.tableName == GRDBDatabaseTable.watchConfig.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -26,7 +26,7 @@ struct DatabaseTableProtocolTests { } @Test("CarPlayConfigTable conforms to DatabaseTableProtocol") - func testCarPlayConfigTableConformance() throws { + func carPlayConfigTableConformance() throws { let table = CarPlayConfigTable() #expect(table.tableName == GRDBDatabaseTable.carPlayConfig.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -36,7 +36,7 @@ struct DatabaseTableProtocolTests { } @Test("AssistPipelinesTable conforms to DatabaseTableProtocol") - func testAssistPipelinesTableConformance() throws { + func assistPipelinesTableConformance() throws { let table = AssistPipelinesTable() #expect(table.tableName == GRDBDatabaseTable.assistPipelines.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -46,7 +46,7 @@ struct DatabaseTableProtocolTests { } @Test("AppEntityRegistryListForDisplayTable conforms to DatabaseTableProtocol") - func testAppEntityRegistryListForDisplayTableConformance() throws { + func appEntityRegistryListForDisplayTableConformance() throws { let table = AppEntityRegistryListForDisplayTable() #expect(table.tableName == GRDBDatabaseTable.appEntityRegistryListForDisplay.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -56,7 +56,7 @@ struct DatabaseTableProtocolTests { } @Test("AppEntityRegistryTable conforms to DatabaseTableProtocol") - func testAppEntityRegistryTableConformance() throws { + func appEntityRegistryTableConformance() throws { let table = AppEntityRegistryTable() #expect(table.tableName == GRDBDatabaseTable.entityRegistry.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -70,7 +70,7 @@ struct DatabaseTableProtocolTests { } @Test("AppDeviceRegistryTable conforms to DatabaseTableProtocol") - func testAppDeviceRegistryTableConformance() throws { + func appDeviceRegistryTableConformance() throws { let table = AppDeviceRegistryTable() #expect(table.tableName == GRDBDatabaseTable.deviceRegistry.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -84,7 +84,7 @@ struct DatabaseTableProtocolTests { } @Test("AppPanelTable conforms to DatabaseTableProtocol") - func testAppPanelTableConformance() throws { + func appPanelTableConformance() throws { let table = AppPanelTable() #expect(table.tableName == GRDBDatabaseTable.appPanel.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -94,7 +94,7 @@ struct DatabaseTableProtocolTests { } @Test("CustomWidgetTable conforms to DatabaseTableProtocol") - func testCustomWidgetTableConformance() throws { + func customWidgetTableConformance() throws { let table = CustomWidgetTable() #expect(table.tableName == GRDBDatabaseTable.customWidget.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -104,7 +104,7 @@ struct DatabaseTableProtocolTests { } @Test("AppAreaTable conforms to DatabaseTableProtocol") - func testAppAreaTableConformance() throws { + func appAreaTableConformance() throws { let table = AppAreaTable() #expect(table.tableName == GRDBDatabaseTable.appArea.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -114,7 +114,7 @@ struct DatabaseTableProtocolTests { } @Test("HomeViewConfigurationTable conforms to DatabaseTableProtocol") - func testHomeViewConfigurationTableConformance() throws { + func homeViewConfigurationTableConformance() throws { let table = HomeViewConfigurationTable() #expect(table.tableName == GRDBDatabaseTable.homeViewConfiguration.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -124,7 +124,7 @@ struct DatabaseTableProtocolTests { } @Test("CameraListConfigurationTable conforms to DatabaseTableProtocol") - func testCameraListConfigurationTableConformance() throws { + func cameraListConfigurationTableConformance() throws { let table = CameraListConfigurationTable() #expect(table.tableName == GRDBDatabaseTable.cameraListConfiguration.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -134,7 +134,7 @@ struct DatabaseTableProtocolTests { } @Test("AssistConfigurationTable conforms to DatabaseTableProtocol") - func testAssistConfigurationTableConformance() throws { + func assistConfigurationTableConformance() throws { let table = AssistConfigurationTable() #expect(table.tableName == GRDBDatabaseTable.assistConfiguration.rawValue) #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") @@ -143,10 +143,10 @@ struct DatabaseTableProtocolTests { #expect(Set(table.definedColumns) == Set(expectedColumns)) } - @Test("All 13 tables conform to DatabaseTableProtocol") - func testAllTablesConformToProtocol() throws { + @Test("All 14 tables conform to DatabaseTableProtocol") + func allTablesConformToProtocol() throws { let tables = DatabaseQueue.tables() - #expect(tables.count == 13, "Should have exactly 13 tables") + #expect(tables.count == 14, "Should have exactly 14 tables") for table in tables { // Verify each table has a non-empty tableName diff --git a/Tests/Shared/Database/GRDB+Initialization.test.swift b/Tests/Shared/Database/GRDB+Initialization.test.swift index a134226f3..1fe05c5cf 100644 --- a/Tests/Shared/Database/GRDB+Initialization.test.swift +++ b/Tests/Shared/Database/GRDB+Initialization.test.swift @@ -16,7 +16,7 @@ struct GRDBInitializationTests { } @Test("Database path in test environment") - func testDatabasePathInTestEnvironment() throws { + func databasePathInTestEnvironment() throws { // The test environment variable should already be set by the test framework let path = DatabaseQueue.databasePath() @@ -27,14 +27,14 @@ struct GRDBInitializationTests { ) } - @Test("Tables returns exactly 13 tables") - func testTablesReturns13Tables() throws { + @Test("Tables returns exactly 14 tables") + func tablesReturns14Tables() throws { let tables = DatabaseQueue.tables() - #expect(tables.count == 13, "DatabaseQueue.tables() should return exactly 13 tables") + #expect(tables.count == 14, "DatabaseQueue.tables() should return exactly 14 tables") } @Test("Tables contains all expected table names") - func testTablesContainsAllExpectedTables() throws { + func tablesContainsAllExpectedTables() throws { let tables = DatabaseQueue.tables() let tableNames = tables.map(\.tableName) @@ -64,7 +64,7 @@ struct GRDBInitializationTests { } @Test("App database creates all tables") - func testAppDatabaseCreatesAllTables() throws { + func appDatabaseCreatesAllTables() throws { // Create a test database using the static method let testDatabasePath = makeTestDatabasePath() defer { cleanupDatabase(at: testDatabasePath) } @@ -90,7 +90,7 @@ struct GRDBInitializationTests { } @Test("deleteOldTables removes clientEvent table") - func testDeleteOldTablesRemovesClientEventTable() throws { + func deleteOldTablesRemovesClientEventTable() throws { let testDatabasePath = makeTestDatabasePath() defer { cleanupDatabase(at: testDatabasePath) } @@ -121,7 +121,7 @@ struct GRDBInitializationTests { } @Test("deleteOldTables handles missing table gracefully") - func testDeleteOldTablesHandlesMissingTableGracefully() throws { + func deleteOldTablesHandlesMissingTableGracefully() throws { let testDatabasePath = makeTestDatabasePath() defer { cleanupDatabase(at: testDatabasePath) } @@ -138,7 +138,7 @@ struct GRDBInitializationTests { } @Test("Table creation error logs to ClientEventStore") - func testTableCreationErrorLogsToClientEventStore() throws { + func tableCreationErrorLogsToClientEventStore() throws { let testDatabasePath = makeTestDatabasePath() defer { cleanupDatabase(at: testDatabasePath) } @@ -184,7 +184,7 @@ struct GRDBInitializationTests { } @Test("Multiple tables can coexist") - func testMultipleTablesCanCoexist() throws { + func multipleTablesCanCoexist() throws { let testDatabasePath = makeTestDatabasePath() defer { cleanupDatabase(at: testDatabasePath) } diff --git a/Tests/Shared/Database/TableSchemaTests.test.swift b/Tests/Shared/Database/TableSchemaTests.test.swift index 2986f8f0e..6053f532f 100644 --- a/Tests/Shared/Database/TableSchemaTests.test.swift +++ b/Tests/Shared/Database/TableSchemaTests.test.swift @@ -52,7 +52,7 @@ struct TableSchemaTests { } @Test("HAppEntityTable schema validation") - func testHAppEntityTableSchema() throws { + func hAppEntityTableSchema() throws { let table = HAppEntityTable() let expectedColumns = DatabaseTables.AppEntity.allCases.map(\.rawValue) try verifyTableSchema( @@ -63,7 +63,7 @@ struct TableSchemaTests { } @Test("WatchConfigTable schema validation") - func testWatchConfigTableSchema() throws { + func watchConfigTableSchema() throws { let table = WatchConfigTable() let expectedColumns = DatabaseTables.WatchConfig.allCases.map(\.rawValue) try verifyTableSchema( @@ -74,7 +74,7 @@ struct TableSchemaTests { } @Test("CarPlayConfigTable schema validation") - func testCarPlayConfigTableSchema() throws { + func carPlayConfigTableSchema() throws { let table = CarPlayConfigTable() let expectedColumns = DatabaseTables.CarPlayConfig.allCases.map(\.rawValue) try verifyTableSchema( @@ -85,7 +85,7 @@ struct TableSchemaTests { } @Test("AssistPipelinesTable schema validation") - func testAssistPipelinesTableSchema() throws { + func assistPipelinesTableSchema() throws { let table = AssistPipelinesTable() let expectedColumns = DatabaseTables.AssistPipelines.allCases.map(\.rawValue) try verifyTableSchema( @@ -96,7 +96,7 @@ struct TableSchemaTests { } @Test("AppEntityRegistryListForDisplayTable schema validation") - func testAppEntityRegistryListForDisplayTableSchema() throws { + func appEntityRegistryListForDisplayTableSchema() throws { let table = AppEntityRegistryListForDisplayTable() let expectedColumns = DatabaseTables.AppEntityRegistryListForDisplay.allCases.map(\.rawValue) try verifyTableSchema( @@ -107,7 +107,7 @@ struct TableSchemaTests { } @Test("AppEntityRegistryTable schema validation") - func testAppEntityRegistryTableSchema() throws { + func appEntityRegistryTableSchema() throws { let table = AppEntityRegistryTable() // Note: AppEntityRegistryTable filters out .id from definedColumns let expectedColumns = DatabaseTables.EntityRegistry.allCases.map(\.rawValue) @@ -120,7 +120,7 @@ struct TableSchemaTests { } @Test("AppDeviceRegistryTable schema validation") - func testAppDeviceRegistryTableSchema() throws { + func appDeviceRegistryTableSchema() throws { let table = AppDeviceRegistryTable() // Note: AppDeviceRegistryTable filters out .id from definedColumns let expectedColumns = DatabaseTables.DeviceRegistry.allCases.map(\.rawValue) @@ -133,7 +133,7 @@ struct TableSchemaTests { } @Test("AppPanelTable schema validation") - func testAppPanelTableSchema() throws { + func appPanelTableSchema() throws { let table = AppPanelTable() let expectedColumns = DatabaseTables.AppPanel.allCases.map(\.rawValue) try verifyTableSchema( @@ -144,7 +144,7 @@ struct TableSchemaTests { } @Test("CustomWidgetTable schema validation") - func testCustomWidgetTableSchema() throws { + func customWidgetTableSchema() throws { let table = CustomWidgetTable() let expectedColumns = DatabaseTables.CustomWidget.allCases.map(\.rawValue) try verifyTableSchema( @@ -155,7 +155,7 @@ struct TableSchemaTests { } @Test("AppAreaTable schema validation") - func testAppAreaTableSchema() throws { + func appAreaTableSchema() throws { let table = AppAreaTable() let expectedColumns = DatabaseTables.AppArea.allCases.map(\.rawValue) try verifyTableSchema( @@ -166,7 +166,7 @@ struct TableSchemaTests { } @Test("HomeViewConfigurationTable schema validation") - func testHomeViewConfigurationTableSchema() throws { + func homeViewConfigurationTableSchema() throws { let table = HomeViewConfigurationTable() let expectedColumns = DatabaseTables.HomeViewConfiguration.allCases.map(\.rawValue) try verifyTableSchema( @@ -177,7 +177,7 @@ struct TableSchemaTests { } @Test("CameraListConfigurationTable schema validation") - func testCameraListConfigurationTableSchema() throws { + func cameraListConfigurationTableSchema() throws { let table = CameraListConfigurationTable() let expectedColumns = DatabaseTables.CameraListConfiguration.allCases.map(\.rawValue) try verifyTableSchema( @@ -188,7 +188,7 @@ struct TableSchemaTests { } @Test("AssistConfigurationTable schema validation") - func testAssistConfigurationTableSchema() throws { + func assistConfigurationTableSchema() throws { let table = AssistConfigurationTable() let expectedColumns = DatabaseTables.AssistConfiguration.allCases.map(\.rawValue) try verifyTableSchema( @@ -198,13 +198,13 @@ struct TableSchemaTests { ) } - @Test("All 13 tables create successfully together") - func testAllTablesCreateTogether() throws { + @Test("All 14 tables create successfully together") + func allTablesCreateTogether() throws { let database = try DatabaseQueue(path: ":memory:") let tables = DatabaseQueue.tables() - // Verify we have exactly 13 tables - #expect(tables.count == 13, "Should have exactly 13 tables, but found \(tables.count)") + // Verify we have exactly 14 tables + #expect(tables.count == 14, "Should have exactly 14 tables, but found \(tables.count)") // Create all tables for table in tables {