diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 2ba567f09..77d465f45 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -478,6 +478,7 @@ 1A3CC1D75E8EEF7294146BD1 /* ControlFanValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */; }; 20226C5AB77E1229852ADDC8 /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D27653D385E4CEB58E52A350 /* Pods_iOS_Extensions_Widgets.framework */; }; 237993F7E11DC585E29EDC7C /* Pods-iOS-Extensions-NotificationService-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */; }; + 293AEC642DB67B7FE828B5B8 /* KioskSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0B0BCA2B73086D731D7BBB9 /* KioskSettings.swift */; }; 2F50FC61669812D485E608EC /* Pods-iOS-Extensions-PushProvider-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */; }; 368048FC64829A4E4B82B631 /* Pods_watchOS_WatchExtension_Watch.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */; }; 38A4EBA18ADEEE555AD14F52 /* Pods-iOS-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */; }; @@ -1199,13 +1200,16 @@ 539AA1653F4BCDB61FE7C696 /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 213EF66D14F92AF8BF2E9E98 /* Pods_iOS_Shared_iOS.framework */; }; 5B715903CB3450FE351399BC /* Pods-iOS-Extensions-Share-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 207E35C8F1554A9AD616FFA2 /* Pods-iOS-Extensions-Share-metadata.plist */; }; 5FFBC80F835393915C4748CF /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A370326321B07E5ACE0BCB65 /* Pods_iOS_Extensions_PushProvider.framework */; }; + 63ABD3F5C77060864AC733A0 /* IconMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E3186320463545E1611267 /* IconMapper.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 78BE7D5D003D9F8C7486DD69 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F4DFB087A3A43F9A526B851 /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; + 7CD62B734FCBFA53E2E3785B /* KioskConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94806334A2D888994C97EADA /* KioskConstants.swift */; }; 81A0C1BBDEFF4F8C5FC314BE /* Pods_iOS_Extensions_NotificationContent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1F356D0219C7F8A24234511B /* Pods_iOS_Extensions_NotificationContent.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 */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; + B4477660EF1D088BA7B7AB72 /* TouchFeedbackManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D2B3B0A4973EB1E4803AC39 /* TouchFeedbackManager.swift */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; B60248001FBD343000998205 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; B605C891226E9DAC00EF46DD /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B605C890226E9DAC00EF46DD /* Permissions.swift */; }; @@ -1499,6 +1503,7 @@ D0EEF322214DE56B00D1D360 /* LocationTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EEF321214DE56B00D1D360 /* LocationTrigger.swift */; }; D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E857A11CB1CCCC00F96925 /* Utils.swift */; }; D0EEF335214EB77100D1D360 /* CLLocation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C17E20D1F64D00BD810B /* CLLocation+Extensions.swift */; }; + E852019F482791D503512FD1 /* AnimationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B076CB3C85BD28DDD3750A71 /* AnimationUtilities.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 */; }; @@ -2957,6 +2962,7 @@ 7150FCF154251F240E33FF76 /* Pods-iOS-Extensions-NotificationContent.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.beta.xcconfig"; sourceTree = ""; }; 755DF7AFFAA21F6CE428E998 /* Pods-watchOS-WatchExtension-Watch.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.release.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.release.xcconfig"; sourceTree = ""; }; 7A6E8DF7DED57BAD4EF47D11 /* Pods_iOS_Extensions_Today.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Today.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7D2B3B0A4973EB1E4803AC39 /* TouchFeedbackManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TouchFeedbackManager.swift; sourceTree = ""; }; 7D94AB7BD65F15C8FEE0912E /* Pods-iOS-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.release.xcconfig"; sourceTree = ""; }; 80854D28D2FCD1482E92ED31 /* Pods-Tests-App.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.beta.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.beta.xcconfig"; sourceTree = ""; }; 8965FD50AC78F092CEB5F076 /* Pods-iOS-Extensions-Widgets.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.beta.xcconfig"; sourceTree = ""; }; @@ -2964,8 +2970,10 @@ 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; 8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; }; 8E00CA53EFBB621A8470C22A /* Pods-watchOS-WatchExtension-Watch.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; sourceTree = ""; }; + 90E3186320463545E1611267 /* IconMapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IconMapper.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 = ""; }; 943E024774CF54EADF771379 /* Pods_iOS_Extensions_Matter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Matter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 94806334A2D888994C97EADA /* KioskConstants.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; 97F089744D425CAB2755F843 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = ""; }; 9C4E5E21229D98220044C8EC /* HomeAssistant.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = HomeAssistant.debug.xcconfig; sourceTree = ""; }; 9C4E5E22229D98530044C8EC /* HomeAssistant.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.release.xcconfig; sourceTree = ""; }; @@ -2977,6 +2985,7 @@ A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_watchOS_WatchExtension_Watch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; ADC769271BB34C474C2D1E24 /* Pods-iOS-Shared-iOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-metadata.plist"; sourceTree = ""; }; AF744211EE471EE671F7C928 /* Pods-iOS-Extensions-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.debug.xcconfig"; sourceTree = ""; }; + B076CB3C85BD28DDD3750A71 /* AnimationUtilities.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnimationUtilities.swift; sourceTree = ""; }; B086E41966E89AE531E3C1A5 /* Pods-iOS-Extensions-Widgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.debug.xcconfig"; sourceTree = ""; }; B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; sourceTree = ""; }; B6022212226DAC9D00E8DBFE /* ScaledFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaledFont.swift; sourceTree = ""; }; @@ -3340,6 +3349,7 @@ DA2CE827B2DBBDBFB11559DF /* Pods-iOS-App.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.beta.xcconfig"; sourceTree = ""; }; DD90A8F251D0671EFAC931ED /* Pods_iOS_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DFE9B91096F09C0E2A124B76 /* Pods-iOS-Extensions-Widgets.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.release.xcconfig"; sourceTree = ""; }; + E0B0BCA2B73086D731D7BBB9 /* KioskSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskSettings.swift; sourceTree = ""; }; E1A08868E5F1AEA7C24FAAAE /* 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 = ""; }; E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-PushProvider-metadata.plist"; path = "Pods/Pods-iOS-Extensions-PushProvider-metadata.plist"; sourceTree = ""; }; E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = ""; }; @@ -6254,6 +6264,17 @@ path = Views; sourceTree = ""; }; + 42FFE02BD35FDDE2B24E9D93 /* Utilities */ = { + isa = PBXGroup; + children = ( + 90E3186320463545E1611267 /* IconMapper.swift */, + 7D2B3B0A4973EB1E4803AC39 /* TouchFeedbackManager.swift */, + B076CB3C85BD28DDD3750A71 /* AnimationUtilities.swift */, + ); + name = Utilities; + path = Utilities; + sourceTree = ""; + }; 46C3EB192D721000009A893F /* Utilities */ = { isa = PBXGroup; children = ( @@ -6301,6 +6322,17 @@ path = Fan; sourceTree = ""; }; + 628607ABBFDAA13310FB776E /* Kiosk */ = { + isa = PBXGroup; + children = ( + E0B0BCA2B73086D731D7BBB9 /* KioskSettings.swift */, + 94806334A2D888994C97EADA /* KioskConstants.swift */, + 42FFE02BD35FDDE2B24E9D93 /* Utilities */, + ); + name = Kiosk; + path = Sources/App/Kiosk; + sourceTree = ""; + }; 9C4E5E20229D97FA0044C8EC /* Configuration */ = { isa = PBXGroup; children = ( @@ -6619,6 +6651,7 @@ B679B1FA1E1F3D020071D366 /* Utilities */, 11A71C6924A463EE00D9565F /* ZoneManager */, B69933961E232AF50054453D /* Resources */, + 628607ABBFDAA13310FB776E /* Kiosk */, ); path = App; sourceTree = ""; @@ -9321,6 +9354,11 @@ 42F1DA632B4D54CB002729BC /* CarPlayTemplateProvider.swift in Sources */, 4206FFBA2DAD58DB0087626C /* ConnectionInfo+WebView.swift in Sources */, 42C0FD0A2DDB2047001016D6 /* AVMetadataObject.ObjectType+HA.swift in Sources */, + 293AEC642DB67B7FE828B5B8 /* KioskSettings.swift in Sources */, + 7CD62B734FCBFA53E2E3785B /* KioskConstants.swift in Sources */, + 63ABD3F5C77060864AC733A0 /* IconMapper.swift in Sources */, + B4477660EF1D088BA7B7AB72 /* TouchFeedbackManager.swift in Sources */, + E852019F482791D503512FD1 /* AnimationUtilities.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/Kiosk/KioskConstants.swift b/Sources/App/Kiosk/KioskConstants.swift new file mode 100755 index 000000000..9b7865235 --- /dev/null +++ b/Sources/App/Kiosk/KioskConstants.swift @@ -0,0 +1,162 @@ +import CoreGraphics +import Foundation + +// 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 + /// Spring animation response + public static let springResponse: Double = 0.4 + } + + // MARK: - Timing Intervals + + public enum Timing { + /// Motion detection cooldown period + public static let motionCooldown: TimeInterval = 2.0 + /// Recently fired trigger display duration (nanoseconds) + public static let recentlyFiredDuration: UInt64 = 3_000_000_000 + /// Network reconnect delay before refresh + public static let networkReconnectDelay: TimeInterval = 2.0 + /// Panel dismiss delay before app launch + public static let panelDismissDelay: TimeInterval = 0.2 + /// Feedback display duration + public static let feedbackDuration: TimeInterval = 1.5 + /// Schedule check interval + public static let scheduleCheckInterval: TimeInterval = 60 + } + + // MARK: - Motion Detection + + public enum Motion { + /// Low sensitivity threshold + public static let lowThreshold: Float = 0.05 + /// Medium sensitivity threshold + public static let mediumThreshold: Float = 0.02 + /// High sensitivity threshold + public static let highThreshold: Float = 0.008 + /// Frame rate for motion detection (fps) + public static let frameRate: Int32 = 5 + } + + // MARK: - Audio Detection + + public enum Audio { + /// Default loud audio threshold in dB + public static let loudThresholdDB: Float = -20 + /// Default quiet threshold in dB + public static let quietThresholdDB: Float = -50 + /// Sample interval for metering + public static let sampleInterval: TimeInterval = 0.1 + /// Consecutive samples needed to confirm loud audio + public static let loudSampleThreshold: Int = 3 + } + + // MARK: - Battery + + public enum Battery { + /// Critical battery level (20%) + public static let criticalLevel: Float = 0.20 + /// Low battery level (25%) + public static let lowLevel: Float = 0.25 + /// Medium battery level (50%) + public static let mediumLevel: Float = 0.50 + /// High battery level (75%) + public static let highLevel: Float = 0.75 + } + + // MARK: - UI Dimensions + + public enum UI { + /// Edge gesture detection size + public static let edgeGestureSize: CGFloat = 30 + /// Swipe gesture threshold + public static let swipeThreshold: CGFloat = 50 + /// Standard corner radius + public static let cornerRadius: CGFloat = 12 + /// Small corner radius + public static let smallCornerRadius: CGFloat = 8 + /// Standard padding + public static let standardPadding: CGFloat = 16 + /// Small padding + public static let smallPadding: CGFloat = 8 + /// 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 + /// Header height + public static let headerHeight: CGFloat = 60 + /// Icon size for app shortcuts + public static let appIconSize: CGFloat = 50 + /// Camera pip size + public static let cameraPipWidth: CGFloat = 300 + /// Camera pip height + public static let cameraPipHeight: CGFloat = 225 + } + + // MARK: - Panel Sizes + + public enum Panel { + /// Maximum panel width ratio + public static let maxWidthRatio: CGFloat = 0.9 + /// Maximum panel width absolute + public static let maxWidth: CGFloat = 400 + /// Maximum panel height ratio + public static let maxHeightRatio: CGFloat = 0.6 + /// Maximum panel height absolute + public static let maxHeight: CGFloat = 500 + /// Minimum shortcuts before showing search + public static let searchThreshold: Int = 6 + } + + // MARK: - Shadows + + public enum Shadow { + /// Standard shadow opacity + public static let opacity: Double = 0.2 + /// Standard shadow radius + public static let radius: CGFloat = 4 + /// Panel shadow opacity + public static let panelOpacity: Double = 0.3 + /// Panel shadow radius + public static let panelRadius: CGFloat = 10 + } + + // MARK: - Accessibility + + public enum Accessibility { + /// Connection status label + public static let connectionStatus = "Connection Status" + /// Connected hint + public static let connectedHint = "Connected to Home Assistant" + /// Disconnected hint + public static let disconnectedHint = "Disconnected from Home Assistant" + /// Battery status label + public static let batteryStatus = "Battery Status" + /// Time display label + public static let timeDisplay = "Current Time" + /// Close button label + public static let closeButton = "Close" + /// Search field label + public static let searchField = "Search apps" + /// App shortcut label format + public static func appShortcut(_ name: String) -> String { + "Launch \(name)" + } + } +} diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift new file mode 100644 index 000000000..40c71234c --- /dev/null +++ b/Sources/App/Kiosk/KioskSettings.swift @@ -0,0 +1,809 @@ +import Foundation +import UIKit + +// 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 + var isKioskModeEnabled: Bool = false + + /// Whether biometric (Face ID/Touch ID) can be used to exit kiosk mode + var allowBiometricExit: Bool = true + + /// Whether device passcode can be used as fallback to exit + var allowDevicePasscodeExit: Bool = true + + /// Lock navigation (disable back gestures, pull-to-refresh, etc.) + var navigationLockdown: Bool = true + + /// Hide iOS status bar for full immersion + var hideStatusBar: Bool = true + + /// Prevent iOS from auto-locking the screen + var preventAutoLock: Bool = true + + /// Prevent accidental edge touches + var edgeProtection: Bool = false + + /// Edge protection inset in points + var edgeProtectionInset: CGFloat = 20 + + // MARK: - Dashboard Configuration + + /// Primary dashboard URL/path + var primaryDashboardURL: String = "" + + /// Append ?kiosk to dashboard URLs (for kiosk-mode HACS integration) + var appendKioskParameter: Bool = false + + /// All configured dashboards + var dashboards: [DashboardConfig] = [] + + /// Dashboard schedule entries + var dashboardSchedule: [DashboardScheduleEntry] = [] + + /// Enable dashboard rotation + var rotationEnabled: Bool = false + + /// Rotation interval in seconds + var rotationInterval: TimeInterval = 60 + + /// Pause rotation when user touches screen + var pauseRotationOnTouch: Bool = true + + /// Resume rotation after this many seconds of idle + var resumeRotationAfterIdle: TimeInterval = 30 + + // MARK: - Auto Refresh + + /// Legacy: Enable automatic refresh (kept for backwards compatibility) + /// Now controlled by autoRefreshInterval > 0 + var autoRefreshEnabled: Bool = true + + /// Periodic refresh interval in seconds (0 = never) + var autoRefreshInterval: TimeInterval = 0 + + /// Refresh when waking from screensaver/dim + var refreshOnWake: Bool = true + + /// Refresh when network reconnects + var refreshOnNetworkReconnect: Bool = true + + /// Refresh when HA WebSocket reconnects + var refreshOnHAReconnect: Bool = true + + // MARK: - Brightness Control + + /// Enable brightness management + var brightnessControlEnabled: Bool = true + + /// Manual brightness level (0.0 - 1.0) + var manualBrightness: Float = 0.8 + + /// Enable day/night brightness schedule + var brightnessScheduleEnabled: Bool = false + + /// Daytime brightness level (0.0 - 1.0) + var dayBrightness: Float = 0.8 + + /// Nighttime brightness level (0.0 - 1.0) + var nightBrightness: Float = 0.3 + + /// Time when day brightness starts (hour, minute) + var dayStartTime: TimeOfDay = TimeOfDay(hour: 7, minute: 0) + + /// Time when night brightness starts (hour, minute) + var nightStartTime: TimeOfDay = TimeOfDay(hour: 22, minute: 0) + + // MARK: - Screensaver + + /// Enable screensaver + var screensaverEnabled: Bool = true + + /// Screensaver mode + var screensaverMode: ScreensaverMode = .clock + + /// Seconds of idle before screensaver activates + var screensaverTimeout: TimeInterval = 300 // 5 minutes + + /// Brightness level when dimmed (0.0 - 1.0) - used when schedule is disabled + var screensaverDimLevel: Float = 0.1 + + /// Enable day/night screensaver brightness schedule (uses same times as main brightness) + var screensaverBrightnessScheduleEnabled: Bool = false + + /// Daytime screensaver brightness level (0.0 - 1.0) + var screensaverDayDimLevel: Float = 0.15 + + /// Nighttime screensaver brightness level (0.0 - 1.0) + var screensaverNightDimLevel: Float = 0.05 + + /// Enable pixel shifting for OLED burn-in prevention + var pixelShiftEnabled: Bool = true + + /// Pixel shift amount in points + var pixelShiftAmount: CGFloat = 10 + + /// Pixel shift interval in seconds + var pixelShiftInterval: TimeInterval = 60 + + // MARK: - Screensaver Clock Options + + /// Show seconds on clock + var clockShowSeconds: Bool = false + + /// Show date on clock + var clockShowDate: Bool = true + + /// Use 24-hour time format (false = 12-hour with AM/PM) + var clockUse24HourFormat: Bool = true + + /// Clock style + var clockStyle: ClockStyle = .large + + /// HA entities to display on clock screensaver + var clockEntities: [ClockEntityConfig] = [] + + // MARK: - Screensaver Weather + + /// Show weather on screensaver + var clockShowWeather: Bool = false + + /// Weather entity to display (e.g., weather.home) + var clockWeatherEntity: String = "" + + /// Temperature entity for more accurate temp display (optional, e.g., sensor.outdoor_temperature) + var clockTemperatureEntity: String = "" + + // MARK: - Screensaver Photos + + /// Photo source for screensaver + var photoSource: PhotoSource = .local + + /// Selected local photo album identifiers + var localPhotoAlbums: [String] = [] + + /// iCloud shared album identifiers + var iCloudAlbums: [String] = [] + + /// HA Media Browser path for photos + var haMediaPath: String = "" + + /// Photo display interval in seconds + var photoInterval: TimeInterval = 30 + + /// Photo transition style + var photoTransition: PhotoTransition = .fade + + /// Photo fit mode (fill or fit) + var photoFitMode: PhotoFitMode = .fill + + /// Show clock overlay on photos + var photoShowClockOverlay: Bool = true + + /// Show entity data overlay on photos + var photoShowEntityOverlay: Bool = false + + // MARK: - Screensaver Custom URL + + /// Custom URL to load as screensaver (e.g., a minimal HA dashboard) + var screensaverCustomURL: String = "" + + // MARK: - Wake/Sleep Triggers + + /// Wake screen on touch + var wakeOnTouch: Bool = true + + /// Wake screen on camera motion detection + var wakeOnCameraMotion: Bool = false + + /// Wake screen on camera presence/face detection + var wakeOnCameraPresence: Bool = false + + /// External HA entities that trigger wake + var wakeEntities: [EntityTrigger] = [] + + /// External HA entities that trigger sleep + var sleepEntities: [EntityTrigger] = [] + + /// Wake schedule entries + var wakeSchedule: [ScheduleEntry] = [] + + /// Sleep schedule entries + var sleepSchedule: [ScheduleEntry] = [] + + // MARK: - Entity Action Triggers + + /// Entity state changes that trigger actions + var entityTriggers: [EntityActionTrigger] = [] + + // MARK: - Camera & Presence Detection + + /// Enable camera-based motion detection + var cameraMotionEnabled: Bool = false + + /// Motion detection sensitivity + var cameraMotionSensitivity: MotionSensitivity = .medium + + /// Enable person presence detection (Vision framework) + var cameraPresenceEnabled: Bool = false + + /// Enable face detection (more accurate than presence) + var cameraFaceDetectionEnabled: Bool = false + + /// Report motion to HA as sensor + var reportMotionToHA: Bool = true + + /// Report presence to HA as sensor + var reportPresenceToHA: Bool = true + + // MARK: - Camera Popup + + /// Camera popup size when showing doorbell/security cameras + var cameraPopupSize: CameraPopupSize = .large + + /// Camera popup position on screen + var cameraPopupPosition: CameraPopupPosition = .center + + // MARK: - Audio + + /// Enable TTS announcements + var ttsEnabled: Bool = true + + /// TTS volume (0.0 - 1.0) + var ttsVolume: Float = 0.7 + + /// Enable audio alerts for critical events + var audioAlertsEnabled: Bool = true + + /// Enable ambient audio level detection + var ambientAudioDetectionEnabled: Bool = false + + // MARK: - App Launcher + + /// Configured app shortcuts + var appShortcuts: [AppShortcut] = [] + + /// Show quick launch panel + var quickLaunchEnabled: Bool = false + + /// Quick launch gesture + var quickLaunchGesture: QuickLaunchGesture = .swipeFromBottom + + /// Return reminder timeout in seconds (0 = disabled) + var appLaunchReturnTimeout: TimeInterval = 300 // 5 minutes + + // MARK: - Status Overlay + + /// Show status overlay bar + var statusOverlayEnabled: Bool = true + + /// Status overlay position + var statusOverlayPosition: OverlayPosition = .top + + /// Show connection status indicator + var showConnectionStatus: Bool = true + + /// Show current time + var showTime: Bool = false + + /// Show battery indicator + var showBattery: Bool = true + + /// HA entities to show in status overlay + var statusOverlayEntities: [String] = [] + + /// Auto-hide overlay after seconds (0 = always visible) + var statusOverlayAutoHide: TimeInterval = 5 + + // MARK: - Quick Actions + + /// Enable quick actions bar + var quickActionsEnabled: Bool = false + + /// Quick action gesture to reveal + var quickActionsGesture: QuickLaunchGesture = .swipeFromRight + + /// Configured quick actions + var quickActions: [QuickAction] = [] + + // MARK: - Device & Security + + /// Orientation lock setting + var orientationLock: OrientationLock = .current + + /// Enable tamper detection (orientation change alerts) + var tamperDetectionEnabled: Bool = false + + /// Enable touch feedback sounds + var touchSoundEnabled: Bool = false + + /// Enable touch haptic feedback + var touchHapticEnabled: Bool = true + + /// Auto-restart app on crash + var autoRestartOnCrash: Bool = true + + /// Low battery alert threshold (0-100, 0 = disabled) + var lowBatteryAlertThreshold: Int = 20 + + /// Report thermal state to HA + var reportThermalState: Bool = true + + // MARK: - Secret Exit Gesture + + /// Enable secret gesture to access kiosk settings (escape hatch) + var secretExitGestureEnabled: Bool = true + + /// Corner for secret exit gesture + var secretExitGestureCorner: ScreenCorner = .topLeft + + /// Number of taps required for secret exit gesture + var secretExitGestureTaps: Int = 3 + + // MARK: - Enhanced Security (Sprint 9) + + /// Enable Guided Access integration + var guidedAccessEnabled: Bool = false + + /// Allow remote lock/unlock from HA + var remoteLockEnabled: Bool = true + + /// Current remote lock state (managed by HA) + var isRemotelyLocked: Bool = false + + /// Maximum charging level (battery health - 0 = disabled) + var maxChargingLevel: Int = 0 + + /// Enable thermal throttling warnings + var thermalThrottlingWarnings: Bool = true + + /// Report battery health metrics to HA + var reportBatteryHealth: Bool = true + + /// Locked orientation (used for tamper detection) + var lockedOrientation: DeviceOrientation? + + /// Expected orientation for tamper detection + var expectedOrientation: DeviceOrientation = .landscape + + /// Enable settings export capability + var allowSettingsExport: Bool = true +} + +// MARK: - Supporting Types + +public struct TimeOfDay: Codable, Equatable { + var hour: Int + var minute: Int + + var asDateComponents: DateComponents { + DateComponents(hour: hour, minute: minute) + } + + func isBefore(_ other: TimeOfDay) -> Bool { + if hour != other.hour { + return hour < other.hour + } + return minute < other.minute + } +} + +public struct DashboardConfig: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var name: String + var url: String + var icon: String = "mdi:view-dashboard" + + /// Whether this is included in rotation + var includeInRotation: Bool = true +} + +public struct DashboardScheduleEntry: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var dashboardId: String + var startTime: TimeOfDay + var endTime: TimeOfDay + var daysOfWeek: [Int] = [1, 2, 3, 4, 5, 6, 7] // 1 = Sunday +} + +public struct ScheduleEntry: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var time: TimeOfDay + var daysOfWeek: [Int] = [1, 2, 3, 4, 5, 6, 7] + var enabled: Bool = true +} + +public struct EntityTrigger: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var entityId: String + var triggerState: String // "on", "off", "home", etc. + var delay: TimeInterval = 0 // Debounce delay + var enabled: Bool = true +} + +public struct EntityActionTrigger: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var entityId: String + var triggerState: String + var action: TriggerAction + var delay: TimeInterval = 0 // Debounce delay before action fires + var duration: TimeInterval? // Auto-revert after this time (nil = permanent) + var enabled: Bool = true +} + +/// Actions that can be triggered by entity state changes +public enum TriggerAction: Codable, Equatable { + /// Navigate the kiosk to a specific URL or dashboard path + case navigate(url: String) + /// Set the screen brightness (0.0 to 1.0) + case setBrightness(level: Float) + /// Start the screensaver with optional mode override + case startScreensaver(mode: ScreensaverMode?) + /// Stop the screensaver and return to normal view + case stopScreensaver + /// Refresh the current dashboard + case refresh + /// Play an audio file from URL + case playSound(url: String) + /// Speak text using text-to-speech + case tts(message: String) +} + +public struct ClockEntityConfig: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var entityId: String + var label: String? // Custom label (nil = use friendly name) + var icon: String? // Custom icon (nil = use entity icon) + var showUnit: Bool = true + var displayFormat: EntityDisplayFormat = .auto + var decimalPlaces: Int? // nil = auto, 0 = integer, 1-3 = decimal places + var prefix: String? // Text before value (e.g., "$") + var suffix: String? // Text after value (replaces unit if set) +} + +public enum EntityDisplayFormat: String, Codable, CaseIterable { + case auto = "auto" // Automatic based on entity type + case value = "value" // Just the value + case valueWithUnit = "value_unit" // Value + unit (e.g., "72°F") + case valueSpaceUnit = "value_space_unit" // Value space unit (e.g., "72 °F") + case integer = "integer" // Round to integer + case percentage = "percentage" // Show as percentage + case compact = "compact" // Compact number (e.g., 1.2K) + case time = "time" // Format as time duration + + var displayName: String { + switch self { + case .auto: return "Auto" + case .value: return "Value Only" + case .valueWithUnit: return "Value + Unit" + case .valueSpaceUnit: return "Value (space) Unit" + case .integer: return "Integer" + case .percentage: return "Percentage" + case .compact: return "Compact" + case .time: return "Duration" + } + } +} + +public struct AppShortcut: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var name: String + var urlScheme: String + var icon: String = "mdi:application" + var systemImage: String? // SF Symbol name (preferred over MDI) +} + +public struct QuickAction: Codable, Equatable, Identifiable { + public var id: String = UUID().uuidString + var name: String + var icon: String + var actionType: QuickActionType +} + +/// Types of actions available for quick action buttons +public enum QuickActionType: Codable, Equatable { + /// Call a Home Assistant service + /// - Parameters: + /// - domain: Service domain (e.g., "light", "switch", "script") + /// - service: Service name (e.g., "turn_on", "toggle") + /// - data: Service data as key-value pairs + case haService(domain: String, service: String, data: [String: String]) + /// Navigate to a URL or dashboard path + case navigate(url: String) + /// Toggle an entity's state using homeassistant.toggle + case toggleEntity(entityId: String) + /// Run a script entity + case script(entityId: String) + /// Activate a scene entity + case scene(entityId: String) +} + +// MARK: - Enums + +public enum ScreensaverMode: String, Codable, CaseIterable { + case blank = "blank" + case dim = "dim" + case clock = "clock" + case clockWithEntities = "clock_entities" + case photos = "photos" + case photosWithClock = "photos_clock" + case customURL = "custom_url" + + var displayName: String { + switch self { + case .blank: return "Blank (Black Screen)" + case .dim: return "Dim Dashboard" + case .clock: return "Clock" + case .clockWithEntities: return "Clock + Sensors" + case .photos: return "Photo Frame" + case .photosWithClock: return "Photos + Clock" + case .customURL: return "Custom Dashboard" + } + } +} + +public enum ClockStyle: String, Codable, CaseIterable { + case large = "large" + case minimal = "minimal" + case analog = "analog" + case digital = "digital" + + var displayName: String { + switch self { + case .large: return "Large" + case .minimal: return "Minimal" + case .analog: return "Analog" + case .digital: return "Digital" + } + } +} + +public enum PhotoSource: String, Codable, CaseIterable { + case local = "local" + case iCloud = "icloud" + case haMedia = "ha_media" + case all = "all" + + var displayName: String { + switch self { + case .local: return "On This Device" + case .iCloud: return "iCloud Photos" + case .haMedia: return "Home Assistant Media" + case .all: return "All Sources" + } + } +} + +public enum PhotoTransition: String, Codable, CaseIterable { + case fade = "fade" + case slide = "slide" + case none = "none" + + var displayName: String { + switch self { + case .fade: return "Fade" + case .slide: return "Slide" + case .none: return "None" + } + } +} + +public enum PhotoFitMode: String, Codable, CaseIterable { + case fill = "fill" + case fit = "fit" + + var displayName: String { + switch self { + case .fill: return "Fill Screen" + case .fit: return "Fit to Screen" + } + } +} + +public enum MotionSensitivity: String, Codable, CaseIterable { + case low = "low" + case medium = "medium" + case high = "high" + + var displayName: String { + switch self { + case .low: return "Low" + case .medium: return "Medium" + case .high: return "High" + } + } +} + +public enum QuickLaunchGesture: String, Codable, CaseIterable { + case swipeFromBottom = "swipe_bottom" + case swipeFromTop = "swipe_top" + case swipeFromLeft = "swipe_left" + case swipeFromRight = "swipe_right" + case doubleTap = "double_tap" + case longPress = "long_press" + + var displayName: String { + switch self { + case .swipeFromBottom: return "Swipe from Bottom" + case .swipeFromTop: return "Swipe from Top" + case .swipeFromLeft: return "Swipe from Left" + case .swipeFromRight: return "Swipe from Right" + case .doubleTap: return "Double Tap" + case .longPress: return "Long Press" + } + } +} + +public enum OverlayPosition: String, Codable, CaseIterable { + case top = "top" + case bottom = "bottom" + + var displayName: String { + switch self { + case .top: return "Top" + case .bottom: return "Bottom" + } + } +} + +public enum OrientationLock: String, Codable, CaseIterable { + case current = "current" + case portrait = "portrait" + case portraitUpsideDown = "portrait_upside_down" + case landscape = "landscape" + case landscapeLeft = "landscape_left" + case landscapeRight = "landscape_right" + + var displayName: String { + switch self { + case .current: return "Current Orientation" + case .portrait: return "Portrait" + case .portraitUpsideDown: return "Portrait (Upside Down)" + case .landscape: return "Landscape (Any)" + case .landscapeLeft: return "Landscape Left" + case .landscapeRight: return "Landscape Right" + } + } +} + +// MARK: - Screen State (for sensors) + +public enum ScreenState: String, Codable { + case on = "on" + case dimmed = "dimmed" + case screensaver = "screensaver" + case off = "off" +} + +public enum AppState: String, Codable { + case active = "active" + case away = "away" + case background = "background" +} + +public enum ScreenCorner: String, Codable, CaseIterable { + case topLeft = "top_left" + case topRight = "top_right" + case bottomLeft = "bottom_left" + case bottomRight = "bottom_right" + + var displayName: String { + switch self { + case .topLeft: return "Top Left" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomRight: return "Bottom Right" + } + } +} + +public enum CameraPopupSize: String, Codable, CaseIterable { + case small = "small" + case medium = "medium" + case large = "large" + case fullScreen = "full_screen" + + var displayName: String { + switch self { + case .small: return "Small" + case .medium: return "Medium" + case .large: return "Large" + case .fullScreen: return "Full Screen" + } + } + + /// Returns width percentage and max width in points + var sizeParameters: (widthPercent: CGFloat, maxWidth: CGFloat, heightPercent: CGFloat) { + switch self { + case .small: return (0.4, 320, 0.4) + case .medium: return (0.55, 450, 0.5) + case .large: return (0.7, 600, 0.6) + case .fullScreen: return (0.95, 1200, 0.9) + } + } +} + +public enum CameraPopupPosition: String, Codable, CaseIterable { + case center = "center" + case topLeft = "top_left" + case topRight = "top_right" + case bottomLeft = "bottom_left" + case bottomRight = "bottom_right" + + var displayName: String { + switch self { + case .center: return "Center" + case .topLeft: return "Top Left" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomRight: return "Bottom Right" + } + } +} + +public enum DeviceOrientation: String, Codable, CaseIterable { + case portrait = "portrait" + case portraitUpsideDown = "portrait_upside_down" + case landscapeLeft = "landscape_left" + case landscapeRight = "landscape_right" + case landscape = "landscape" + case faceUp = "face_up" + case faceDown = "face_down" + case unknown = "unknown" + + var displayName: String { + switch self { + case .portrait: return "Portrait" + case .portraitUpsideDown: return "Portrait (Upside Down)" + case .landscapeLeft: return "Landscape Left" + case .landscapeRight: return "Landscape Right" + case .landscape: return "Landscape" + case .faceUp: return "Face Up" + case .faceDown: return "Face Down" + case .unknown: return "Unknown" + } + } + + static func from(_ uiOrientation: UIDeviceOrientation) -> DeviceOrientation { + switch uiOrientation { + case .portrait: return .portrait + case .portraitUpsideDown: return .portraitUpsideDown + case .landscapeLeft: return .landscapeLeft + case .landscapeRight: return .landscapeRight + case .faceUp: return .faceUp + case .faceDown: return .faceDown + default: return .unknown + } + } + + func matches(_ other: DeviceOrientation) -> Bool { + if self == other { return true } + // Landscape matches both left and right + if self == .landscape && (other == .landscapeLeft || other == .landscapeRight) { + return true + } + if other == .landscape && (self == .landscapeLeft || self == .landscapeRight) { + return true + } + return false + } +} + +// MARK: - Default App Shortcuts + +extension AppShortcut { + static let defaults: [AppShortcut] = [ + AppShortcut(name: "Safari", urlScheme: "x-web-search://", icon: "mdi:safari", systemImage: "safari"), + AppShortcut(name: "Music", urlScheme: "music://", icon: "mdi:music", systemImage: "music.note"), + AppShortcut(name: "Spotify", urlScheme: "spotify://", icon: "mdi:spotify", systemImage: nil), + AppShortcut(name: "Settings", urlScheme: "App-prefs://", icon: "mdi:cog", systemImage: "gear"), + AppShortcut(name: "Camera", urlScheme: "camera://", icon: "mdi:camera", systemImage: "camera"), + AppShortcut(name: "UniFi Protect", urlScheme: "uiprotect://", icon: "mdi:shield-home", systemImage: nil), + ] +} diff --git a/Sources/App/Kiosk/Utilities/AnimationUtilities.swift b/Sources/App/Kiosk/Utilities/AnimationUtilities.swift new file mode 100755 index 000000000..dfebcd58e --- /dev/null +++ b/Sources/App/Kiosk/Utilities/AnimationUtilities.swift @@ -0,0 +1,232 @@ +import SwiftUI + +// MARK: - Kiosk Animations + +/// Pre-defined animations for consistent UX across the kiosk mode +public enum KioskAnimation { + /// Quick fade animation for subtle transitions + public static let fade = Animation.easeInOut(duration: KioskConstants.Animation.quick) + + /// Standard animation for most transitions + public static let standard = Animation.easeInOut(duration: KioskConstants.Animation.standard) + + /// Slow animation for screensaver transitions + public static let slow = Animation.easeInOut(duration: KioskConstants.Animation.slow) + + /// Spring animation for interactive elements + public static let spring = Animation.spring(response: KioskConstants.Animation.springResponse, dampingFraction: 0.8) + + /// Bouncy spring for playful interactions + public static let bouncy = Animation.spring(response: 0.5, dampingFraction: 0.6) + + /// Smooth spring for panels and overlays + public static let panel = Animation.spring(response: 0.4, dampingFraction: 0.85) + + /// Gentle animation for pixel shift + public static let pixelShift = Animation.easeInOut(duration: KioskConstants.Animation.pixelShift) +} + +// MARK: - Kiosk Transitions + +/// Pre-defined transitions for consistent UX +public enum KioskTransition { + /// Fade in/out + public static let fade = AnyTransition.opacity + + /// Scale and fade + public static let scaleAndFade = AnyTransition.scale.combined(with: .opacity) + + /// Slide from bottom with fade + public static let slideFromBottom = AnyTransition.move(edge: .bottom).combined(with: .opacity) + + /// Slide from top with fade + public static let slideFromTop = AnyTransition.move(edge: .top).combined(with: .opacity) + + /// Slide from left with fade + public static let slideFromLeft = AnyTransition.move(edge: .leading).combined(with: .opacity) + + /// Slide from right with fade + public static let slideFromRight = AnyTransition.move(edge: .trailing).combined(with: .opacity) + + /// Asymmetric panel transition (slide in, fade out) + public static func panel(edge: Edge) -> AnyTransition { + .asymmetric( + insertion: .move(edge: edge).combined(with: .opacity), + removal: .opacity + ) + } + + /// Screensaver transition (slow fade) + public static let screensaver = AnyTransition.opacity.animation(KioskAnimation.slow) + + /// Alert transition (scale up, fade out) + public static let alert = AnyTransition.asymmetric( + insertion: .scale(scale: 0.9).combined(with: .opacity), + removal: .opacity + ) +} + +// MARK: - View Modifiers + +/// Applies a smooth appear animation +public struct AppearAnimationModifier: ViewModifier { + let animation: Animation + @State private var isVisible = false + + public init(animation: Animation = KioskAnimation.standard) { + self.animation = animation + } + + public func body(content: Content) -> some View { + content + .opacity(isVisible ? 1 : 0) + .scaleEffect(isVisible ? 1 : 0.95) + .onAppear { + withAnimation(animation) { + isVisible = true + } + } + } +} + +/// Applies a pulse animation (useful for drawing attention) +public struct PulseAnimationModifier: ViewModifier { + @State private var isPulsing = false + let duration: Double + + public init(duration: Double = 1.0) { + self.duration = duration + } + + public func body(content: Content) -> some View { + content + .scaleEffect(isPulsing ? 1.05 : 1.0) + .opacity(isPulsing ? 0.8 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: duration).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + } +} + +/// Applies a shimmer loading animation +public struct ShimmerModifier: ViewModifier { + @State private var phase: CGFloat = 0 + + public func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geometry in + LinearGradient( + gradient: Gradient(colors: [ + .clear, + .white.opacity(0.3), + .clear, + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geometry.size.width * 2) + .offset(x: -geometry.size.width + phase * geometry.size.width * 2) + } + ) + .mask(content) + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} + +// MARK: - View Extensions + +extension View { + /// Apply smooth appear animation + public func appearAnimation(_ animation: Animation = KioskAnimation.standard) -> some View { + modifier(AppearAnimationModifier(animation: animation)) + } + + /// Apply pulse animation + public func pulseAnimation(duration: Double = 1.0) -> some View { + modifier(PulseAnimationModifier(duration: duration)) + } + + /// Apply shimmer loading animation + public func shimmer() -> some View { + modifier(ShimmerModifier()) + } + + /// Apply a smooth scale effect on press + public func pressEffect() -> some View { + buttonStyle(PressEffectButtonStyle()) + } +} + +// MARK: - Button Styles + +/// Button style that scales down slightly on press +public struct PressEffectButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .opacity(configuration.isPressed ? 0.9 : 1.0) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +/// Button style with spring bounce effect +public struct BounceButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.9 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.5), value: configuration.isPressed) + } +} + +/// Button style that highlights on press +public struct HighlightButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: KioskConstants.UI.smallCornerRadius) + .fill(configuration.isPressed ? Color.accentColor.opacity(0.1) : Color.clear) + ) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +// MARK: - Animated Property Wrappers + +/// Property wrapper that animates value changes +@propertyWrapper +public struct Animated: DynamicProperty { + @State private var value: Value + private let animation: Animation + + public init(wrappedValue: Value, animation: Animation = .default) { + _value = State(initialValue: wrappedValue) + self.animation = animation + } + + public var wrappedValue: Value { + get { value } + nonmutating set { + withAnimation(animation) { + value = newValue + } + } + } + + public var projectedValue: Binding { + Binding( + get: { value }, + set: { newValue in + withAnimation(animation) { + value = newValue + } + } + ) + } +} diff --git a/Sources/App/Kiosk/Utilities/IconMapper.swift b/Sources/App/Kiosk/Utilities/IconMapper.swift new file mode 100755 index 000000000..c66bc75cf --- /dev/null +++ b/Sources/App/Kiosk/Utilities/IconMapper.swift @@ -0,0 +1,165 @@ +import Foundation + +// MARK: - Icon Mapper + +/// Utility for mapping Material Design Icons (MDI) to SF Symbols +public enum IconMapper { + /// Comprehensive mapping of MDI icons to SF Symbols + public static let mdiToSFSymbol: [String: String] = [ + // General + "mdi:application": "app.fill", + "mdi:cog": "gear", + "mdi:settings": "gear", + "mdi:power": "power", + "mdi:check": "checkmark", + "mdi:close": "xmark", + "mdi:plus": "plus", + "mdi:minus": "minus", + "mdi:search": "magnifyingglass", + "mdi:refresh": "arrow.clockwise", + + // Browser + "mdi:safari": "safari.fill", + "mdi:web": "globe", + "mdi:link": "link", + + // Media & Audio + "mdi:music": "music.note", + "mdi:spotify": "music.note.list", + "mdi:play": "play.fill", + "mdi:pause": "pause.fill", + "mdi:stop": "stop.fill", + "mdi:skip-next": "forward.fill", + "mdi:skip-previous": "backward.fill", + "mdi:volume-high": "speaker.wave.3.fill", + "mdi:volume-medium": "speaker.wave.2.fill", + "mdi:volume-low": "speaker.wave.1.fill", + "mdi:volume-off": "speaker.slash.fill", + "mdi:speaker": "speaker.wave.2", + + // Camera & Video + "mdi:camera": "camera.fill", + "mdi:video": "video.fill", + "mdi:cctv": "video", + "mdi:video-doorbell": "video.doorbell.fill", + + // Communication + "mdi:message": "message.fill", + "mdi:phone": "phone.fill", + "mdi:email": "envelope.fill", + "mdi:bell": "bell.fill", + "mdi:bell-outline": "bell", + + // Time & Calendar + "mdi:clock": "clock.fill", + "mdi:calendar": "calendar", + "mdi:alarm": "alarm", + "mdi:timer": "timer", + + // Weather + "mdi:weather-sunny": "sun.max", + "mdi:weather-cloudy": "cloud", + "mdi:weather-rainy": "cloud.rain", + "mdi:weather-partly-cloudy": "cloud.sun", + "mdi:weather-snowy": "cloud.snow", + "mdi:weather-windy": "wind", + "mdi:thermometer": "thermometer", + "mdi:water-percent": "humidity", + "mdi:humidity": "humidity", + + // Home & Security + "mdi:home": "house.fill", + "mdi:home-assistant": "house.fill", + "mdi:shield": "shield.fill", + "mdi:shield-home": "shield.fill", + "mdi:lock": "lock.fill", + "mdi:lock-open": "lock.open.fill", + "mdi:door": "door.left.hand.open", + "mdi:door-open": "door.left.hand.open", + "mdi:door-closed": "door.left.hand.closed", + "mdi:window-open": "window.vertical.open", + "mdi:window-closed": "window.vertical.closed", + "mdi:garage": "rectangle.split.3x1", + "mdi:motion-sensor": "figure.walk.motion", + "mdi:run": "figure.run", + + // Lighting + "mdi:lightbulb": "lightbulb.fill", + "mdi:lightbulb-on": "lightbulb.fill", + "mdi:lightbulb-off": "lightbulb.slash", + "mdi:lamp": "lamp.desk.fill", + "mdi:ceiling-light": "light.recessed", + "mdi:floor-lamp": "lamp.floor.fill", + + // HVAC & Climate + "mdi:thermostat": "thermostat", + "mdi:fan": "fan", + "mdi:air-conditioner": "air.conditioner.horizontal", + "mdi:snowflake": "snowflake", + "mdi:fire": "flame.fill", + + // Power & Energy + "mdi:flash": "bolt.fill", + "mdi:lightning-bolt": "bolt.fill", + "mdi:battery": "battery.100", + "mdi:battery-charging": "battery.100.bolt", + "mdi:solar-power": "sun.max.trianglebadge.exclamationmark", + "mdi:ev-station": "bolt.car", + "mdi:power-plug": "powerplug.fill", + "mdi:gas-station": "fuelpump", + + // Appliances + "mdi:washing-machine": "washer", + "mdi:dishwasher": "dishwasher", + "mdi:fridge": "refrigerator", + "mdi:stove": "stove", + "mdi:microwave": "microwave", + "mdi:coffee": "cup.and.saucer", + "mdi:vacuum": "humidifier.and.droplets", + + // Devices + "mdi:television": "tv", + "mdi:monitor": "display", + "mdi:laptop": "laptopcomputer", + "mdi:tablet": "ipad", + "mdi:cellphone": "iphone", + "mdi:wifi": "wifi", + "mdi:bluetooth": "antenna.radiowaves.left.and.right", + "mdi:printer": "printer", + "mdi:router": "wifi.router", + + // People & Location + "mdi:account": "person.fill", + "mdi:account-multiple": "person.2.fill", + "mdi:car": "car.fill", + "mdi:map": "map.fill", + "mdi:map-marker": "mappin", + "mdi:navigation": "location.north.fill", + + // Sensors & Gauges + "mdi:gauge": "gauge", + "mdi:speedometer": "speedometer", + "mdi:sensor": "sensor.fill", + "mdi:water": "drop.fill", + + // Dashboard + "mdi:view-dashboard": "rectangle.grid.2x2.fill", + "mdi:view-dashboard-outline": "rectangle.grid.2x2", + ] + + /// Convert an MDI icon name to an SF Symbol + /// - Parameter mdiName: The MDI icon name (e.g., "mdi:thermometer") + /// - Returns: The corresponding SF Symbol name, or "questionmark.circle" if not found + public static func sfSymbol(from mdiName: String) -> String { + mdiToSFSymbol[mdiName] ?? "questionmark.circle" + } + + /// Convert an MDI icon name to an SF Symbol, with a custom default + /// - Parameters: + /// - mdiName: The MDI icon name + /// - defaultSymbol: The default SF Symbol to use if no mapping exists + /// - Returns: The corresponding SF Symbol name + public static func sfSymbol(from mdiName: String, default defaultSymbol: String) -> String { + mdiToSFSymbol[mdiName] ?? defaultSymbol + } +} diff --git a/Sources/App/Kiosk/Utilities/TouchFeedbackManager.swift b/Sources/App/Kiosk/Utilities/TouchFeedbackManager.swift new file mode 100755 index 000000000..c2e1fa2b1 --- /dev/null +++ b/Sources/App/Kiosk/Utilities/TouchFeedbackManager.swift @@ -0,0 +1,214 @@ +import AudioToolbox +import AVFoundation +import UIKit + +// MARK: - Touch Feedback Manager + +/// Manages haptic and sound feedback for touch interactions +@MainActor +public final class TouchFeedbackManager { + // MARK: - Singleton + + public static let shared = TouchFeedbackManager() + + // MARK: - Feedback Types + + public enum FeedbackType { + /// Light tap feedback (button taps) + case tap + /// Medium impact feedback (selections, toggles) + case selection + /// Heavy impact feedback (important actions) + case action + /// Success feedback (completed actions) + case success + /// Warning feedback (alerts, confirmations) + case warning + /// Error feedback (failures) + case error + } + + // MARK: - System Sound IDs + + /// iOS system sound IDs for feedback + /// Reference: https://iphonedev.wiki/AudioServices + private enum SystemSound: SystemSoundID { + case tock = 1104 // Light tap sound + case tink = 1105 // Selection change sound + case keyPress = 1306 // Key pressed sound + case bloom = 1025 // Payment success / positive confirmation + case tone = 1255 // Alert tone + case negativeTone = 1257 // Error / negative confirmation + } + + // MARK: - Private Properties + + private var settings: KioskSettings { KioskModeManager.shared.settings } + + // Haptic generators (lazy initialized for performance) + private lazy var lightImpactGenerator = UIImpactFeedbackGenerator(style: .light) + private lazy var mediumImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + private lazy var heavyImpactGenerator = UIImpactFeedbackGenerator(style: .heavy) + private lazy var selectionGenerator = UISelectionFeedbackGenerator() + private lazy var notificationGenerator = UINotificationFeedbackGenerator() + + // Sound player + private var audioPlayer: AVAudioPlayer? + + // MARK: - Initialization + + private init() { + prepareGenerators() + } + + // MARK: - Public Methods + + /// Play feedback for a given type + public func playFeedback(for type: FeedbackType) { + if settings.touchHapticEnabled { + playHaptic(for: type) + } + + if settings.touchSoundEnabled { + playSound(for: type) + } + } + + /// Play haptic feedback only + public func playHaptic(for type: FeedbackType) { + guard settings.touchHapticEnabled else { return } + + switch type { + case .tap: + lightImpactGenerator.impactOccurred() + + case .selection: + selectionGenerator.selectionChanged() + + case .action: + mediumImpactGenerator.impactOccurred() + + case .success: + notificationGenerator.notificationOccurred(.success) + + case .warning: + notificationGenerator.notificationOccurred(.warning) + + case .error: + notificationGenerator.notificationOccurred(.error) + } + } + + /// Play sound feedback only + public func playSound(for type: FeedbackType) { + guard settings.touchSoundEnabled else { return } + + let sound: SystemSound + switch type { + case .tap: + sound = .tock + case .selection: + sound = .tink + case .action: + sound = .keyPress + case .success: + sound = .bloom + case .warning: + sound = .tone + case .error: + sound = .negativeTone + } + + AudioServicesPlaySystemSound(sound.rawValue) + } + + /// Prepare haptic generators for responsiveness + public func prepareGenerators() { + lightImpactGenerator.prepare() + mediumImpactGenerator.prepare() + heavyImpactGenerator.prepare() + selectionGenerator.prepare() + notificationGenerator.prepare() + } + + // MARK: - Convenience Methods + + /// Play tap feedback (for button touches) + public func tap() { + playFeedback(for: .tap) + } + + /// Play selection feedback (for toggles, selections) + public func selection() { + playFeedback(for: .selection) + } + + /// Play action feedback (for executing commands) + public func action() { + playFeedback(for: .action) + } + + /// Play success feedback + public func success() { + playFeedback(for: .success) + } + + /// Play warning feedback + public func warning() { + playFeedback(for: .warning) + } + + /// Play error feedback + public func error() { + playFeedback(for: .error) + } +} + +// MARK: - SwiftUI View Modifier + +/// View modifier that adds touch feedback to any view +public struct TouchFeedbackModifier: ViewModifier { + let feedbackType: TouchFeedbackManager.FeedbackType + + public func body(content: Content) -> some View { + content + .simultaneousGesture( + TapGesture() + .onEnded { _ in + TouchFeedbackManager.shared.playFeedback(for: feedbackType) + } + ) + } +} + +// MARK: - View Extension + +import SwiftUI + +extension View { + /// Add touch feedback to a view + public func touchFeedback(_ type: TouchFeedbackManager.FeedbackType = .tap) -> some View { + modifier(TouchFeedbackModifier(feedbackType: type)) + } +} + +// MARK: - UIKit Integration + +extension TouchFeedbackManager { + /// Add touch feedback to a UIButton + public func addFeedback(to button: UIButton, type: FeedbackType = .tap) { + button.addTarget(self, action: #selector(handleButtonTouch), for: .touchUpInside) + // Store the feedback type as associated object + objc_setAssociatedObject(button, &feedbackTypeKey, type, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + @objc private func handleButtonTouch(_ sender: UIButton) { + if let type = objc_getAssociatedObject(sender, &feedbackTypeKey) as? FeedbackType { + playFeedback(for: type) + } else { + playFeedback(for: .tap) + } + } +} + +private var feedbackTypeKey: UInt8 = 0