From e364507025d80009437484947371d5d158aa80c6 Mon Sep 17 00:00:00 2001 From: Hunter Baker <62899372+literally-anything@users.noreply.github.com> Date: Mon, 8 Jul 2024 07:21:40 -0400 Subject: [PATCH] Add action widgets, open page widgets, and gauges on the Lock Screen (#2830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Allow action widget, open page widget, and watchOS style complications to be available on the iOS Lock Screen. ## Screenshots Screenshot 2024-06-30 at 9 28 19 AM Screenshot 2024-06-30 at 11 17 13 AM Screenshot 2024-06-30 at 9 29 26 AM ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes --------- Co-authored-by: Bruno Pantaleão Gonçalves --- HomeAssistant.xcodeproj/project.pbxproj | 102 ++++++++++-- .../Resources/en.lproj/Localizable.strings | 21 +++ .../AppIntents/IntentServerAppEntitiy.swift | 60 +++++++ .../Details/WidgetDetailsAppIntent.swift | 92 +++++++++++ ...dgetDetailsAppIntentTimelineProvider.swift | 134 +++++++++++++++ .../Widget/Gauge/WidgetGaugeAppIntent.swift | 154 ++++++++++++++++++ ...WidgetGaugeAppIntentTimelineProvider.swift | 147 +++++++++++++++++ .../AppIntents/WidgetActionsAppIntent.swift | 8 +- .../Widgets/Actions/WidgetActions.swift | 18 +- .../Widgets/Assist/WidgetAssistView.swift | 14 +- .../Common/WidgetBasicContainerView.swift | 26 ++- .../Widgets/Common/WidgetBasicView.swift | 149 +++++++++-------- .../Widgets/Common/WidgetCircularView.swift | 28 ++++ .../Widgets/Details/WidgetDetails.swift | 44 +++++ .../Widgets/Details/WidgetDetailsView.swift | 45 +++++ .../Widgets/Gauge/WidgetGauge.swift | 41 +++++ .../Widgets/Gauge/WidgetGaugeView.swift | 60 +++++++ .../Widgets/OpenPage/WidgetOpenPage.swift | 12 +- Sources/Extensions/Widgets/Widgets.swift | 4 + .../Sources/NotificationParserLegacy.swift | 27 ++- .../Shared/Common/AppIntentWidgetKinds.swift | 4 + .../NotificationsCommandManager.swift | 16 +- .../Shared/Resources/Swiftgen/Strings.swift | 54 ++++++ 23 files changed, 1147 insertions(+), 113 deletions(-) create mode 100644 Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift create mode 100644 Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift create mode 100644 Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift create mode 100644 Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift create mode 100644 Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift create mode 100644 Sources/Extensions/Widgets/Common/WidgetCircularView.swift create mode 100644 Sources/Extensions/Widgets/Details/WidgetDetails.swift create mode 100644 Sources/Extensions/Widgets/Details/WidgetDetailsView.swift create mode 100644 Sources/Extensions/Widgets/Gauge/WidgetGauge.swift create mode 100644 Sources/Extensions/Widgets/Gauge/WidgetGaugeView.swift create mode 100644 Sources/Shared/Common/AppIntentWidgetKinds.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 99bae1f21..394ea3300 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -503,6 +503,23 @@ 399792712B7F909900231B54 /* MobileAppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792702B7F909900231B54 /* MobileAppConfig.swift */; }; 399792722B7F909900231B54 /* MobileAppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792702B7F909900231B54 /* MobileAppConfig.swift */; }; 39A32EE22C0E384E00985722 /* UIImage+scaledToSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A32EE12C0E384E00985722 /* UIImage+scaledToSize.swift */; }; + 4008F0262C2D0A1A00E24001 /* WidgetCircularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4008F0252C2D0A1A00E24001 /* WidgetCircularView.swift */; }; + 403AE9092C2E220200D48147 /* WidgetGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE9082C2E220200D48147 /* WidgetGauge.swift */; }; + 403AE90E2C2E28B200D48147 /* WidgetGaugeAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE90B2C2E28B200D48147 /* WidgetGaugeAppIntent.swift */; }; + 403AE9102C2E28B200D48147 /* WidgetGaugeAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE90B2C2E28B200D48147 /* WidgetGaugeAppIntent.swift */; }; + 403AE9122C2E2BFC00D48147 /* WidgetGaugeAppIntentTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE9112C2E2BFC00D48147 /* WidgetGaugeAppIntentTimelineProvider.swift */; }; + 403AE9272C2F333A00D48147 /* WidgetGaugeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE9262C2F333A00D48147 /* WidgetGaugeView.swift */; }; + 403AE92A2C2F3A9200D48147 /* IntentServerAppEntitiy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE9292C2F3A9200D48147 /* IntentServerAppEntitiy.swift */; }; + 403AE92B2C2F3A9200D48147 /* IntentServerAppEntitiy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403AE9292C2F3A9200D48147 /* IntentServerAppEntitiy.swift */; }; + 404C79762C3482860010EB81 /* AppIntentWidgetKinds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404C79752C3482790010EB81 /* AppIntentWidgetKinds.swift */; }; + 404C79772C3482860010EB81 /* AppIntentWidgetKinds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404C79752C3482790010EB81 /* AppIntentWidgetKinds.swift */; }; + 404C797F2C3491390010EB81 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B69933931E232AEA0054453D /* Localizable.strings */; }; + 404C79802C3491390010EB81 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B69933931E232AEA0054453D /* Localizable.strings */; }; + 4080D5BE2C319AA000099C88 /* WidgetDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4080D5BD2C319AA000099C88 /* WidgetDetailsView.swift */; }; + 4080D5BF2C319AA000099C88 /* WidgetDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4080D5BC2C319AA000099C88 /* WidgetDetails.swift */; }; + 4080D5C42C319B0A00099C88 /* WidgetDetailsAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4080D5C12C319B0A00099C88 /* WidgetDetailsAppIntent.swift */; }; + 4080D5C52C319B0A00099C88 /* WidgetDetailsAppIntentTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4080D5C22C319B0A00099C88 /* WidgetDetailsAppIntentTimelineProvider.swift */; }; + 4080D5C62C319B0A00099C88 /* WidgetDetailsAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4080D5C12C319B0A00099C88 /* WidgetDetailsAppIntent.swift */; }; 42070EE82BAC43240031E96F /* AssistSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42070EE72BAC43240031E96F /* AssistSession.swift */; }; 42070EEB2BAC517A0031E96F /* AssistInAppIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42070EE92BAC49D70031E96F /* AssistInAppIntentHandler.swift */; }; 42070EEC2BAC517A0031E96F /* AssistInAppIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42070EE92BAC49D70031E96F /* AssistInAppIntentHandler.swift */; }; @@ -589,11 +606,9 @@ 42A818E32BBEA9780083D045 /* MockAudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818E22BBEA9780083D045 /* MockAudioRecorder.swift */; }; 42A818E52BBEAA3A0083D045 /* MockAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818E42BBEAA3A0083D045 /* MockAudioPlayer.swift */; }; 42A818E72BBEAAE80083D045 /* MockAssistService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818E62BBEAAE80083D045 /* MockAssistService.swift */; }; + 42AA4C842C2DACAD00EA2E99 /* UIImage+Circle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AA4C832C2DACAD00EA2E99 /* UIImage+Circle.swift */; }; 42B1A7432C11E65100904548 /* WatchAssistService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B1A7422C11E65100904548 /* WatchAssistService.swift */; }; 42B1A7452C1305C300904548 /* WatchCommunicatorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B1A7442C1305C300904548 /* WatchCommunicatorService.swift */; }; - 42AA4C822C2DA56100EA2E99 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 420B10032B1CF6D800D383D8 /* SharedAssets.xcassets */; }; - 42AA4C842C2DACAD00EA2E99 /* UIImage+Circle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AA4C832C2DACAD00EA2E99 /* UIImage+Circle.swift */; }; - 42B94BDD2B9606CD00DEE060 /* AssistChatItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B94BDA2B9606CD00DEE060 /* AssistChatItem.swift */; }; 42B94BDE2B9606CD00DEE060 /* AssistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B94BDB2B9606CD00DEE060 /* AssistViewModel.swift */; }; 42B94BDF2B9606CD00DEE060 /* AssistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B94BDC2B9606CD00DEE060 /* AssistView.swift */; }; 42B94BEC2B96083C00DEE060 /* AssistModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B94BE72B9607D100DEE060 /* AssistModel.swift */; }; @@ -1660,6 +1675,17 @@ 399792702B7F909900231B54 /* MobileAppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileAppConfig.swift; sourceTree = ""; }; 39A32EE12C0E384E00985722 /* UIImage+scaledToSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+scaledToSize.swift"; sourceTree = ""; }; 3F4DFB087A3A43F9A526B851 /* Pods_iOS_Shared_iOS_Tests_Shared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Shared_iOS_Tests_Shared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4008F0252C2D0A1A00E24001 /* WidgetCircularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCircularView.swift; sourceTree = ""; }; + 403AE9082C2E220200D48147 /* WidgetGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetGauge.swift; sourceTree = ""; }; + 403AE90B2C2E28B200D48147 /* WidgetGaugeAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WidgetGaugeAppIntent.swift; path = Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift; sourceTree = SOURCE_ROOT; }; + 403AE9112C2E2BFC00D48147 /* WidgetGaugeAppIntentTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetGaugeAppIntentTimelineProvider.swift; sourceTree = ""; }; + 403AE9262C2F333A00D48147 /* WidgetGaugeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetGaugeView.swift; sourceTree = ""; }; + 403AE9292C2F3A9200D48147 /* IntentServerAppEntitiy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentServerAppEntitiy.swift; sourceTree = ""; }; + 404C79752C3482790010EB81 /* AppIntentWidgetKinds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentWidgetKinds.swift; sourceTree = ""; }; + 4080D5BC2C319AA000099C88 /* WidgetDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDetails.swift; sourceTree = ""; }; + 4080D5BD2C319AA000099C88 /* WidgetDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDetailsView.swift; sourceTree = ""; }; + 4080D5C12C319B0A00099C88 /* WidgetDetailsAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WidgetDetailsAppIntent.swift; path = Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift; sourceTree = SOURCE_ROOT; }; + 4080D5C22C319B0A00099C88 /* WidgetDetailsAppIntentTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDetailsAppIntentTimelineProvider.swift; sourceTree = ""; }; 42070EE72BAC43240031E96F /* AssistSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistSession.swift; sourceTree = ""; }; 42070EE92BAC49D70031E96F /* AssistInAppIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistInAppIntentHandler.swift; sourceTree = ""; }; 420B10032B1CF6D800D383D8 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = ""; }; @@ -1786,9 +1812,9 @@ 42A818E22BBEA9780083D045 /* MockAudioRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAudioRecorder.swift; sourceTree = ""; }; 42A818E42BBEAA3A0083D045 /* MockAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAudioPlayer.swift; sourceTree = ""; }; 42A818E62BBEAAE80083D045 /* MockAssistService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAssistService.swift; sourceTree = ""; }; + 42AA4C832C2DACAD00EA2E99 /* UIImage+Circle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Circle.swift"; sourceTree = ""; }; 42B1A7422C11E65100904548 /* WatchAssistService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchAssistService.swift; sourceTree = ""; }; 42B1A7442C1305C300904548 /* WatchCommunicatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommunicatorService.swift; sourceTree = ""; }; - 42AA4C832C2DACAD00EA2E99 /* UIImage+Circle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Circle.swift"; sourceTree = ""; }; 42B94BDA2B9606CD00DEE060 /* AssistChatItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssistChatItem.swift; sourceTree = ""; }; 42B94BDB2B9606CD00DEE060 /* AssistViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssistViewModel.swift; sourceTree = ""; }; 42B94BDC2B9606CD00DEE060 /* AssistView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssistView.swift; sourceTree = ""; }; @@ -2562,6 +2588,7 @@ 115560E227010DAB00A8F818 /* WidgetBasicView.swift */, 424A7F452B188946008C8DF3 /* WidgetBackground.swift */, 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */, + 4008F0252C2D0A1A00E24001 /* WidgetCircularView.swift */, ); path = Common; sourceTree = ""; @@ -2684,6 +2711,8 @@ 115560DF27010D6700A8F818 /* Common */, 110E693E24E770BD004AA96D /* Actions */, 115560EA27012ED000A8F818 /* OpenPage */, + 403AE9072C2E214D00D48147 /* Gauge */, + 4080D5BB2C319A9100099C88 /* Details */, 1171508324DFCF960065E874 /* Resources */, 1171506F24DFCDE60065E874 /* Widgets.swift */, 110E694524E771AB004AA96D /* Color+Hex.swift */, @@ -3263,6 +3292,42 @@ path = WebView; sourceTree = ""; }; + 403AE9072C2E214D00D48147 /* Gauge */ = { + isa = PBXGroup; + children = ( + 403AE9082C2E220200D48147 /* WidgetGauge.swift */, + 403AE9262C2F333A00D48147 /* WidgetGaugeView.swift */, + ); + path = Gauge; + sourceTree = ""; + }; + 403AE90A2C2E28A200D48147 /* Gauge */ = { + isa = PBXGroup; + children = ( + 403AE90B2C2E28B200D48147 /* WidgetGaugeAppIntent.swift */, + 403AE9112C2E2BFC00D48147 /* WidgetGaugeAppIntentTimelineProvider.swift */, + ); + path = Gauge; + sourceTree = ""; + }; + 4080D5BB2C319A9100099C88 /* Details */ = { + isa = PBXGroup; + children = ( + 4080D5BC2C319AA000099C88 /* WidgetDetails.swift */, + 4080D5BD2C319AA000099C88 /* WidgetDetailsView.swift */, + ); + path = Details; + sourceTree = ""; + }; + 4080D5C02C319AF400099C88 /* Details */ = { + isa = PBXGroup; + children = ( + 4080D5C12C319B0A00099C88 /* WidgetDetailsAppIntent.swift */, + 4080D5C22C319B0A00099C88 /* WidgetDetailsAppIntentTimelineProvider.swift */, + ); + path = Details; + sourceTree = ""; + }; 420FE8472B5569ED00878E06 /* Actions */ = { isa = PBXGroup; children = ( @@ -3373,14 +3438,6 @@ path = Domains; sourceTree = ""; }; - 425573D82B57DDC700145217 /* Tests */ = { - isa = PBXGroup; - children = ( - 425573D92B57DDE000145217 /* WindowScenesManager.test.swift */, - ); - path = Tests; - sourceTree = ""; - }; 426266432C11B0070081A818 /* Watch */ = { isa = PBXGroup; children = ( @@ -3458,6 +3515,7 @@ children = ( 4296C3722B91F06D0051B63C /* Widget */, 4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */, + 403AE9292C2F3A9200D48147 /* IntentServerAppEntitiy.swift */, 4296C36C2B90DB630051B63C /* PerformAction.swift */, ); path = AppIntents; @@ -3467,6 +3525,8 @@ isa = PBXGroup; children = ( 4296C3732B91F0730051B63C /* Actions */, + 403AE90A2C2E28A200D48147 /* Gauge */, + 4080D5C02C319AF400099C88 /* Details */, ); path = Widget; sourceTree = ""; @@ -4491,6 +4551,7 @@ D0FF79D020D87CF60034574D /* Common */ = { isa = PBXGroup; children = ( + 404C79752C3482790010EB81 /* AppIntentWidgetKinds.swift */, B6A5D9F4215233EC0013963F /* SiriIntents+ConvenienceInits.swift */, B688AB4621193946002FCAD6 /* ObjectMapperTransformers.swift */, 11E5CF8024BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift */, @@ -5262,6 +5323,7 @@ B606168B1D1F117700249C11 /* US-EN-Alexa-Motion-Detected-Generic.wav in Resources */, 4279407F2B8369EC001D7E14 /* AppIntentVocabulary.plist in Resources */, B606168E1D1F117700249C11 /* US-EN-Alexa-Motion-In-Front-Yard.wav in Resources */, + 404C797F2C3491390010EB81 /* Localizable.strings in Resources */, B60616261D1F117700249C11 /* US-EN-Morgan-Freeman-Motion-In-Garage.wav in Resources */, 420B100C2B1D204400D383D8 /* Assets.xcassets in Resources */, B60616941D1F117800249C11 /* US-EN-Alexa-Smoke-Detected-In-Garage.wav in Resources */, @@ -5462,6 +5524,7 @@ buildActionMask = 2147483647; files = ( B6CC5D882159D10E00833E5D /* Assets.xcassets in Resources */, + 404C79802C3491390010EB81 /* Localizable.strings in Resources */, B6CC5D862159D10D00833E5D /* Interface.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6054,14 +6117,24 @@ 115560E327010DAB00A8F818 /* WidgetBasicView.swift in Sources */, 1171507024DFCDE60065E874 /* Widgets.swift in Sources */, 424A7F462B188946008C8DF3 /* WidgetBackground.swift in Sources */, + 4008F0262C2D0A1A00E24001 /* WidgetCircularView.swift in Sources */, 42F958992BB4684700497981 /* WidgetAssist.swift in Sources */, + 4080D5C52C319B0A00099C88 /* WidgetDetailsAppIntentTimelineProvider.swift in Sources */, + 4080D5C62C319B0A00099C88 /* WidgetDetailsAppIntent.swift in Sources */, 4296C3772B91F26A0051B63C /* IntentActionAppEntity.swift in Sources */, 115560F227012FE100A8F818 /* WidgetOpenPageProvider.swift in Sources */, + 403AE9102C2E28B200D48147 /* WidgetGaugeAppIntent.swift in Sources */, 1165705627018C4E003906A7 /* WidgetEmptyView.swift in Sources */, 1171508124DFCEC50065E874 /* WidgetActions.swift in Sources */, + 403AE92B2C2F3A9200D48147 /* IntentServerAppEntitiy.swift in Sources */, + 403AE9272C2F333A00D48147 /* WidgetGaugeView.swift in Sources */, 4296C3782B91F6260051B63C /* PerformAction.swift in Sources */, + 4080D5BE2C319AA000099C88 /* WidgetDetailsView.swift in Sources */, + 4080D5BF2C319AA000099C88 /* WidgetDetails.swift in Sources */, 4296C37B2B92054C0051B63C /* WidgetActionsAppIntent.swift in Sources */, + 403AE9092C2E220200D48147 /* WidgetGauge.swift in Sources */, 42F9589F2BB4707F00497981 /* WidgetAssistView.swift in Sources */, + 403AE9122C2E2BFC00D48147 /* WidgetGaugeAppIntentTimelineProvider.swift in Sources */, 42F9589C2BB4691D00497981 /* WidgetAssistProvider.swift in Sources */, 110E694624E771AB004AA96D /* Color+Hex.swift in Sources */, ); @@ -6115,6 +6188,7 @@ buildActionMask = 2147483647; files = ( 115F9D7025F4B7B700CC6A45 /* TemplateSection.swift in Sources */, + 403AE92A2C2F3A9200D48147 /* IntentServerAppEntitiy.swift in Sources */, 1101568524D770B2009424C9 /* NFCReader.swift in Sources */, B648AE262275918F006972AF /* Scenes.swift in Sources */, 1185DF9A271FE60F00ED7D9A /* OnboardingAuthStep.swift in Sources */, @@ -6203,6 +6277,7 @@ 111858DF24CB83DF00B8CDDC /* Intents.intentdefinition in Sources */, B64BB3A81E9C6551001E8B46 /* WebViewController.swift in Sources */, 42FCCFE22B9B1B610057783F /* BarcodeScannerCamera.swift in Sources */, + 4080D5C42C319B0A00099C88 /* WidgetDetailsAppIntent.swift in Sources */, 11A71C7124A4648000D9565F /* ZoneManagerEquatableRegion.swift in Sources */, 42FCD0132B9B29740057783F /* ThreadCredentialsManagementViewModel.swift in Sources */, 11E99A5027156854003C8A65 /* OnboardingTerminalViewController.swift in Sources */, @@ -6288,6 +6363,7 @@ 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */, 11EFCDD324F5F39100314D85 /* WebViewWindowController.swift in Sources */, 11EFCDE024F60E5900314D85 /* BasicSceneDelegate.swift in Sources */, + 403AE90E2C2E28B200D48147 /* WidgetGaugeAppIntent.swift in Sources */, 42FCCFFF2B9B1C310057783F /* ThreadCredentialsSharingView.swift in Sources */, 429106872BA9D22500D452F9 /* AudioRecorder.swift in Sources */, 425573C92B5572DB00145217 /* CarPlayServerListViewModel.swift in Sources */, @@ -6455,6 +6531,7 @@ B67CE8B222200F220034C1D0 /* CMMotion+StringExtensions.swift in Sources */, B6B74CB92283983300D58A68 /* WatchComplication.swift in Sources */, 42B94BEC2B96083C00DEE060 /* AssistModel.swift in Sources */, + 404C79762C3482860010EB81 /* AppIntentWidgetKinds.swift in Sources */, 119DE934263325C20099F7D8 /* IconDrawable+Settings.swift in Sources */, 114CBAE92839E49E00A9BAFF /* CustomServerTrustManager.swift in Sources */, 1128FF3D297F49D900BAAFD9 /* Locale+IntentLanguage.swift in Sources */, @@ -6757,6 +6834,7 @@ B6A258482232539900ADD202 /* WebhookUpdateLocation.swift in Sources */, B6B74CBA2283983800D58A68 /* CLKComplication+Strings.swift in Sources */, 119385A4249E8E360097F497 /* StorageSensor.swift in Sources */, + 404C79772C3482860010EB81 /* AppIntentWidgetKinds.swift in Sources */, D05A4D32216DD206009FD1EB /* MJPEGStreamer.swift in Sources */, 426266452C11B02C0081A818 /* InteractiveImmediateMessages.swift in Sources */, 42CE8FB02B46C3D900C707F9 /* CoreStrings+Values.swift in Sources */, diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 1e7b738b4..a57dddc3b 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -844,6 +844,7 @@ Home Assistant is free and open source home automation software with a focus on "watch.labels.complication_text_areas.trailing.label" = "Trailing"; "watch.labels.no_action" = "No actions configured. Configure actions on your phone to dismiss this message."; "watch.placeholder_complication_name" = "Placeholder"; +"widgets.actions.parameters.action" = "Action"; "widgets.actions.description" = "Perform Home Assistant actions."; "widgets.actions.not_configured" = "No Actions Configured"; "widgets.actions.title" = "Actions"; @@ -855,4 +856,24 @@ Home Assistant is free and open source home automation software with a focus on "widgets.open_page.description" = "Open a frontend page in Home Assistant."; "widgets.open_page.not_configured" = "No Pages Available"; "widgets.open_page.title" = "Open Page"; +"widgets.gauge.parameters.gauge_type" = "Gauge Type"; +"widgets.gauge.parameters.gauge_type.normal" = "Normal"; +"widgets.gauge.parameters.gauge_type.capacity" = "Capacity"; +"widgets.gauge.parameters.server" = "Server"; +"widgets.gauge.parameters.value_template" = "Value Template (0-1)"; +"widgets.gauge.parameters.value_label_template" = "Value Label Template"; +"widgets.gauge.parameters.min_label_template" = "Min Label Template"; +"widgets.gauge.parameters.max_label_template" = "Max Label Template"; +"widgets.gauge.parameters.run_action" = "Run Action"; +"widgets.gauge.parameters.action" = "Action"; +"widgets.gauge.description" = "Display numeric states from Home Assistant in a gauge"; +"widgets.gauge.title" = "Gauge"; +"widgets.details.parameters.server" = "Server"; +"widgets.details.parameters.upper_template" = "Upper Text Template"; +"widgets.details.parameters.lower_template" = "Lower Text Template"; +"widgets.details.parameters.details_template" = "Details Text Template (only in rectangular family)"; +"widgets.details.parameters.run_action" = "Run Action (only in rectangular family)"; +"widgets.details.parameters.action" = "Action"; +"widgets.details.description" = "Display states using from Home Assistant in text"; +"widgets.details.title" = "Details"; "yes_label" = "Yes"; \ No newline at end of file diff --git a/Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift b/Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift new file mode 100644 index 000000000..21351bb68 --- /dev/null +++ b/Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift @@ -0,0 +1,60 @@ +import AppIntents +import Foundation +import Shared + +@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) +struct IntentServerAppEntity: AppEntity, Sendable { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "MaterialDesignIcons") + + struct IntentServerAppEntityQuery: EntityQuery, EntityStringQuery { + func entities(for identifiers: [IntentServerAppEntity.ID]) async throws -> [IntentServerAppEntity] { + getServerEntities().filter { identifiers.contains($0.id) } + } + + func entities(matching string: String) async throws -> [IntentServerAppEntity] { + getServerEntities().filter { $0.getInfo()?.remoteName.contains(string) ?? false } + } + + func suggestedEntities() async throws -> [IntentServerAppEntity] { + getServerEntities() + } + + private func getServerEntities() -> [IntentServerAppEntity] { + Current.servers.all.map { IntentServerAppEntity(from: $0) } + } + + func defaultResult() async -> IntentServerAppEntity? { + let server = Current.servers.all.first + if server == nil { + return nil + } else { + return IntentServerAppEntity(from: server!) + } + } + } + + static let defaultQuery = IntentServerAppEntityQuery() + + var id: String + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: .init(stringLiteral: getInfo()?.name ?? "Unknown") + ) + } + + init(identifier: Identifier) { + self.id = identifier.rawValue + } + + init(from server: Server) { + self.init(identifier: server.identifier) + } + + func getServer() -> Server? { + Current.servers.server(for: .init(rawValue: id)) + } + + func getInfo() -> ServerInfo? { + getServer()?.info + } +} diff --git a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift new file mode 100644 index 000000000..6606de4e2 --- /dev/null +++ b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift @@ -0,0 +1,92 @@ +import AppIntents +import AudioToolbox +import Foundation +import Shared + +@available(iOS 17.0, macOS 14.0, watchOS 10.0, *) +struct WidgetDetailsAppIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = .init("widgets.details.title", defaultValue: "Details") + static let description = IntentDescription( + .init("widgets.details.description", defaultValue: "Display states using from Home Assistant in text") + ) + + @Parameter(title: .init("widgets.details.parameters.server", defaultValue: "Server"), default: nil) + var server: IntentServerAppEntity + + @Parameter( + title: .init("widgets.details.parameters.upper_template", defaultValue: "Upper Text Template"), + default: "", + inputOptions: .init( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var upperTemplate: String + + @Parameter( + title: .init("widgets.details.parameters.lower_template", defaultValue: "Lower Text Template"), + default: "", + inputOptions: .init( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var lowerTemplate: String + + @Parameter( + title: .init( + "widgets.details.parameters.details_template", + defaultValue: "Details Text Template (only in rectangular family)" + ), + default: "", + inputOptions: .init( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var detailsTemplate: String + + @Parameter( + title: .init("widgets.details.parameters.run_action", defaultValue: "Run Action (only in rectangular family)"), + default: false + ) + var runAction: Bool + + @Parameter( + title: .init("widgets.details.parameters.action", defaultValue: "Action"), + default: nil + ) + var action: IntentActionAppEntity? + + static var parameterSummary: some ParameterSummary { + When(\WidgetDetailsAppIntent.$runAction, .equalTo, true) { + Summary { + \.$server + \.$upperTemplate + \.$lowerTemplate + \.$detailsTemplate + + \.$runAction + \.$action + } + } otherwise: { + Summary { + \.$server + \.$upperTemplate + \.$lowerTemplate + \.$detailsTemplate + + \.$runAction + } + } + } +} diff --git a/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift new file mode 100644 index 000000000..c4305bb37 --- /dev/null +++ b/Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift @@ -0,0 +1,134 @@ +import AppIntents +import HAKit +import RealmSwift +import Shared +import WidgetKit + +@available(iOS 17, *) +struct WidgetDetailsAppIntentTimelineProvider: AppIntentTimelineProvider { + typealias Entry = WidgetDetailsEntry + typealias Intent = WidgetDetailsAppIntent + + func snapshot(for configuration: WidgetDetailsAppIntent, in context: Context) async -> WidgetDetailsEntry { + do { + return try await entry(for: configuration, in: context) + } catch { + Current.Log.debug("Using placeholder for gauge widget snapshot") + return placeholder(in: context) + } + } + + func timeline(for configuration: WidgetDetailsAppIntent, in context: Context) async -> Timeline { + do { + let snapshot = try await entry(for: configuration, in: context) + return .init( + entries: [snapshot], + policy: .after( + Current.date() + .addingTimeInterval(WidgetDetailsDataSource.expiration.converted(to: .seconds).value) + ) + ) + } catch { + Current.Log.debug("Using placeholder for gauge widget") + return .init( + entries: [placeholder(in: context)], + policy: .after( + Current.date() + .addingTimeInterval(WidgetDetailsDataSource.expiration.converted(to: .seconds).value) + ) + ) + } + } + + func placeholder(in context: Context) -> WidgetDetailsEntry { + .init( + upperText: nil, lowerText: nil, detailsText: nil, + runAction: false, action: nil + ) + } + + private func entry(for configuration: WidgetDetailsAppIntent, in context: Context) async throws -> Entry { + guard Current.servers.all.count > 0 else { + Current.Log.error("Failed to fetch data for details widget: No servers exist") + throw WidgetDetailsDataError.noServers + } + + let server = configuration.server.getServer() ?? Current.servers.all.first! + let api = Current.api(for: server) + + let upperTemplate = !configuration.upperTemplate.isEmpty ? configuration.upperTemplate : "?" + let lowerTemplate = !configuration.lowerTemplate.isEmpty ? configuration.lowerTemplate : "?" + let detailsTemplate = !configuration.detailsTemplate.isEmpty ? configuration.detailsTemplate : "?" + let template = "\(upperTemplate)|\(lowerTemplate)|\(detailsTemplate)" + + let result = await withCheckedContinuation { continuation in + api.connection.send(.init( + type: .rest(.post, "template"), + data: ["template": template], + shouldRetry: true + )) { result in + continuation.resume(returning: result) + } + } + + var data: HAData? + switch result { + case let .success(resultData): + data = resultData + case let .failure(error): + Current.Log.error("Failed to render template for details widget: \(error)") + throw WidgetDetailsDataError.apiError + } + + var renderedTemplate: String? + switch data! { + case let .primitive(response): + renderedTemplate = response as? String + default: + Current.Log.error("Failed to render template for details widget: Bad response data") + throw WidgetDetailsDataError.badResponse + } + + let params = renderedTemplate!.split(separator: "|") + guard params.count == 3 else { + Current.Log.error("Failed to render template for details widget: Wrong length response") + throw WidgetDetailsDataError.badResponse + } + + let upperText = String(params[0]) + let lowerText = String(params[1]) + let detailsText = String(params[2]) + return .init( + upperText: upperText != "?" ? upperText : nil, + lowerText: lowerText != "?" ? lowerText : nil, + detailsText: detailsText != "?" ? detailsText : nil, + + runAction: configuration.runAction, + action: configuration.action?.asAction() + ) + } +} + +enum WidgetDetailsDataSource { + static var expiration: Measurement { + .init(value: 15, unit: .minutes) + } +} + +@available(iOS 17, *) +struct WidgetDetailsEntry: TimelineEntry { + var date = Date() + + var upperText: String? + var lowerText: String? + var detailsText: String? + + var runAction: Bool + var action: Action? +} + +enum WidgetDetailsDataError: Error { + case noServers + case apiError + case badResponse +} diff --git a/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift new file mode 100644 index 000000000..455c8641e --- /dev/null +++ b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntent.swift @@ -0,0 +1,154 @@ +import AppIntents +import AudioToolbox +import Foundation +import Shared + +@available(iOS 17.0, macOS 14.0, watchOS 10.0, *) +struct WidgetGaugeAppIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = .init("widgets.gauge.title", defaultValue: "Actions") + static let description = IntentDescription( + .init("widgets.gauge.description", defaultValue: "Display numeric states from Home Assistant in a gauge") + ) + + @Parameter(title: .init("widgets.gauge.parameters.gauge_type", defaultValue: "Gauge Type"), default: .normal) + var gaugeType: GaugeTypeAppEnum + + @Parameter(title: .init("widgets.gauge.parameters.server", defaultValue: "Server"), default: nil) + var server: IntentServerAppEntity + + @Parameter( + title: .init("widgets.gauge.parameters.value_template", defaultValue: "Value Template (0-1)"), + default: "", + inputOptions: .init( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var valueTemplate: String + + @Parameter( + title: .init("widgets.gauge.parameters.value_label_template", defaultValue: "Value Label Template"), + default: "", + inputOptions: .init( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var valueLabelTemplate: String + + @Parameter( + title: .init("widgets.gauge.parameters.min_label_template", defaultValue: "Min Label Template"), + default: "", + inputOptions: .init( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var minTemplate: String + + @Parameter( + title: .init("widgets.gauge.parameters.max_label_template", defaultValue: "Max Label Template"), + default: "", + inputOptions: .init( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var maxTemplate: String + + @Parameter(title: .init("widgets.gauge.parameters.run_action", defaultValue: "Run Action"), default: false) + var runAction: Bool + + @Parameter(title: .init("widgets.gauge.parameters.action", defaultValue: "Action"), default: nil) + var action: IntentActionAppEntity? + + static var parameterSummary: some ParameterSummary { + When(\WidgetGaugeAppIntent.$runAction, .equalTo, true) { + When(\.$gaugeType, .equalTo, .normal) { + Summary { + \.$gaugeType + + \.$server + \.$valueTemplate + + \.$valueLabelTemplate + \.$minTemplate + \.$maxTemplate + + \.$runAction + \.$action + } + } otherwise: { + Summary { + \.$gaugeType + + \.$server + \.$valueTemplate + + \.$valueLabelTemplate + + \.$runAction + \.$action + } + } + } otherwise: { + When(\.$gaugeType, .equalTo, .normal) { + Summary { + \.$gaugeType + + \.$server + \.$valueTemplate + + \.$valueLabelTemplate + \.$minTemplate + \.$maxTemplate + + \.$runAction + } + } otherwise: { + Summary { + \.$gaugeType + + \.$server + \.$valueTemplate + + \.$valueLabelTemplate + + \.$runAction + } + } + } + } +} + +@available(iOS 17.0, macOS 14.0, watchOS 10.0, *) +enum GaugeTypeAppEnum: String, Codable, Sendable, AppEnum { + case normal + case capacity + + static let typeDisplayRepresentation = TypeDisplayRepresentation( + name: .init("widgets.gauge.parameters.gauge_type", defaultValue: "GaugeType") + ) + static var caseDisplayRepresentations: [GaugeTypeAppEnum: DisplayRepresentation] = [ + .normal: DisplayRepresentation(title: .init( + "widgets.gauge.parameters.gauge_type.normal", + defaultValue: "Normal" + )), + .capacity: DisplayRepresentation(title: .init( + "widgets.gauge.parameters.gauge_type.capacity", + defaultValue: "Capacity" + )), + ] +} diff --git a/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift new file mode 100644 index 000000000..4960a57fe --- /dev/null +++ b/Sources/Extensions/AppIntents/Widget/Gauge/WidgetGaugeAppIntentTimelineProvider.swift @@ -0,0 +1,147 @@ +import AppIntents +import HAKit +import RealmSwift +import Shared +import WidgetKit + +@available(iOS 17, *) +struct WidgetGaugeAppIntentTimelineProvider: AppIntentTimelineProvider { + typealias Entry = WidgetGaugeEntry + typealias Intent = WidgetGaugeAppIntent + + func snapshot(for configuration: WidgetGaugeAppIntent, in context: Context) async -> WidgetGaugeEntry { + do { + return try await entry(for: configuration, in: context) + } catch { + Current.Log.debug("Using placeholder for gauge widget snapshot") + return placeholder(in: context) + } + } + + func timeline(for configuration: WidgetGaugeAppIntent, in context: Context) async -> Timeline { + do { + let snapshot = try await entry(for: configuration, in: context) + return .init( + entries: [snapshot], + policy: .after( + Current.date() + .addingTimeInterval(WidgetGaugeDataSource.expiration.converted(to: .seconds).value) + ) + ) + } catch { + Current.Log.debug("Using placeholder for gauge widget") + return .init( + entries: [placeholder(in: context)], + policy: .after( + Current.date() + .addingTimeInterval(WidgetGaugeDataSource.expiration.converted(to: .seconds).value) + ) + ) + } + } + + func placeholder(in context: Context) -> WidgetGaugeEntry { + .init( + gaugeType: .normal, + value: 0.5, + valueLabel: "?", min: "?", max: "?", + runAction: false, action: nil + ) + } + + private func entry(for configuration: WidgetGaugeAppIntent, in context: Context) async throws -> Entry { + guard Current.servers.all.count > 0 else { + Current.Log.error("Failed to fetch data for gauge widget: No servers exist") + throw WidgetGaugeDataError.noServers + } + + let server = configuration.server.getServer() ?? Current.servers.all.first! + let api = Current.api(for: server) + + let valueTemplate = !configuration.valueTemplate.isEmpty ? configuration.valueTemplate : "0.0" + let valueLabelTemplate = !configuration.valueLabelTemplate.isEmpty ? configuration.valueLabelTemplate : "?" + let maxTemplate = configuration.gaugeType == .normal && !configuration.maxTemplate.isEmpty ? configuration + .maxTemplate : "?" + let minTemplate = configuration.gaugeType == .normal && !configuration.minTemplate.isEmpty ? configuration + .minTemplate : "?" + let template = "\(valueTemplate)|\(valueLabelTemplate)|\(maxTemplate)|\(minTemplate)" + + let result = await withCheckedContinuation { continuation in + api.connection.send(.init( + type: .rest(.post, "template"), + data: ["template": template], + shouldRetry: true + )) { result in + continuation.resume(returning: result) + } + } + + var data: HAData? + switch result { + case let .success(resultData): + data = resultData + case let .failure(error): + Current.Log.error("Failed to render template for gauge widget: \(error)") + throw WidgetGaugeDataError.apiError + } + + var renderedTemplate: String? + switch data! { + case let .primitive(response): + renderedTemplate = response as? String + default: + Current.Log.error("Failed to render template for gauge widget: Bad response data") + throw WidgetGaugeDataError.badResponse + } + + let params = renderedTemplate!.split(separator: "|") + guard params.count == 4 else { + Current.Log.error("Failed to render template for gauge widget: Wrong length response") + throw WidgetGaugeDataError.badResponse + } + + let valueText = String(params[1]) + let maxText = String(params[2]) + let minText = String(params[3]) + return .init( + gaugeType: configuration.gaugeType, + + value: Double(params[0]) ?? 0.0, + + valueLabel: valueText != "?" ? valueText : nil, + min: minText != "?" ? minText : nil, + max: maxText != "?" ? maxText : nil, + + runAction: configuration.runAction, + action: configuration.action?.asAction() + ) + } +} + +enum WidgetGaugeDataSource { + static var expiration: Measurement { + .init(value: 15, unit: .minutes) + } +} + +@available(iOS 17, *) +struct WidgetGaugeEntry: TimelineEntry { + var date = Date() + + var gaugeType: GaugeTypeAppEnum + + var value: Double + + var valueLabel: String? + var min: String? + var max: String? + + var runAction: Bool + var action: Action? +} + +enum WidgetGaugeDataError: Error { + case noServers + case apiError + case badResponse +} diff --git a/Sources/Extensions/AppIntents/WidgetActionsAppIntent.swift b/Sources/Extensions/AppIntents/WidgetActionsAppIntent.swift index e51db4ae5..bef28c318 100644 --- a/Sources/Extensions/AppIntents/WidgetActionsAppIntent.swift +++ b/Sources/Extensions/AppIntents/WidgetActionsAppIntent.swift @@ -8,11 +8,13 @@ struct WidgetActionsAppIntent: AppIntent, WidgetConfigurationIntent, CustomInten ProgressReportingIntent { static let intentClassName = "WidgetActionsIntent" - static let title: LocalizedStringResource = "Actions" - static let description = IntentDescription("View and run actions") + static let title: LocalizedStringResource = .init("widgets.actions.title", defaultValue: "Actions") + static let description = IntentDescription( + .init("widgets.actions.description", defaultValue: "Perform Home Assistant actions.") + ) @Parameter( - title: "Actions", + title: .init("widgets.actions.parameters.action", defaultValue: "Action"), size: [ .systemSmall: 1, .systemMedium: 8, diff --git a/Sources/Extensions/Widgets/Actions/WidgetActions.swift b/Sources/Extensions/Widgets/Actions/WidgetActions.swift index 1153b3fc8..ff2b75360 100644 --- a/Sources/Extensions/Widgets/Actions/WidgetActions.swift +++ b/Sources/Extensions/Widgets/Actions/WidgetActions.swift @@ -65,7 +65,7 @@ struct LegacyWidgetActions: Widget { .contentMarginsDisabledIfAvailable() .configurationDisplayName(L10n.Widgets.Actions.title) .description(L10n.Widgets.Actions.description) - .supportedFamilies(WidgetActionSupportedFamilies.families) + .supportedFamilies(WidgetActionSupportedFamilies.legacyFamilies) .onBackgroundURLSessionEvents(matching: nil) { identifier, completion in Current.webhooks.handleBackground(for: identifier, completionHandler: completion) } @@ -73,5 +73,19 @@ struct LegacyWidgetActions: Widget { } enum WidgetActionSupportedFamilies { - static let families: [WidgetFamily] = [.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge] + @available(iOS 16.0, *) + static let families: [WidgetFamily] = [ + .systemSmall, + .systemMedium, + .systemLarge, + .systemExtraLarge, + .accessoryCircular, + ] + + static let legacyFamilies: [WidgetFamily] = [ + .systemSmall, + .systemMedium, + .systemLarge, + .systemExtraLarge, + ] } diff --git a/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift b/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift index d9e743dcf..0b94f0fa4 100644 --- a/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift +++ b/Sources/Extensions/Widgets/Assist/WidgetAssistView.swift @@ -36,19 +36,7 @@ struct WidgetAssistView: View { } private var accessoryCircular: some View { - VStack(spacing: 2) { - Image(uiImage: MaterialDesignIcons.messageProcessingOutlineIcon.image( - ofSize: .init(width: 24, height: 24), - color: .white - )) - .foregroundStyle(.ultraThickMaterial) - Image(imageAsset: Asset.SharedAssets.logo) - .resizable() - .frame(width: 10, height: 10) - } - .padding() - .background(Color(uiColor: .secondarySystemBackground)) - .clipShape(Circle()) + WidgetCircularView(icon: MaterialDesignIcons.messageProcessingOutlineIcon) } private var singleHomeScreenItem: some View { diff --git a/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift b/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift index cb1fd24ca..00fedc244 100644 --- a/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift +++ b/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift @@ -28,8 +28,11 @@ struct WidgetBasicContainerView: View { func singleView(for model: WidgetBasicViewModel) -> some View { ZStack { - model.backgroundColor - .opacity(0.8) + // Check if the widget should be transparent (on the lock screen) + if !Self.transparentFamilies.contains(family) { + model.backgroundColor + .opacity(0.8) + } if case let .widgetURL(url) = model.interactionType { WidgetBasicView(model: model, sizeStyle: .single) .widgetURL(url.withWidgetAuthenticity()) @@ -85,9 +88,12 @@ struct WidgetBasicContainerView: View { HStack(spacing: pixelLength) { ForEach(column) { model in ZStack { - // stacking the color under makes the Link's highlight state nicer - model.backgroundColor - .opacity(0.8) + // Check if the widget should be transparent (on the lock screen) + if !Self.transparentFamilies.contains(family) { + // stacking the color under makes the Link's highlight state nicer + model.backgroundColor + .opacity(0.8) + } if case let .widgetURL(url) = model.interactionType { Link(destination: url.withWidgetAuthenticity()) { WidgetBasicView(model: model, sizeStyle: sizeStyle) @@ -170,4 +176,14 @@ struct WidgetBasicContainerView: View { @unknown default: return 8 } } + + // This is all widgets that are on the lock screen + // Lock screen widgets are transparent and don't need a colored background + private static var transparentFamilies: [WidgetFamily] { + if #available(iOS 16.0, *) { + [.accessoryCircular, .accessoryRectangular] + } else { + [] + } + } } diff --git a/Sources/Extensions/Widgets/Common/WidgetBasicView.swift b/Sources/Extensions/Widgets/Common/WidgetBasicView.swift index 4813d8ba0..2022bd397 100644 --- a/Sources/Extensions/Widgets/Common/WidgetBasicView.swift +++ b/Sources/Extensions/Widgets/Common/WidgetBasicView.swift @@ -105,6 +105,8 @@ enum WidgetBasicSizeStyle { } struct WidgetBasicView: View { + @Environment(\.widgetFamily) private var widgetFamily + private let model: WidgetBasicViewModel private let sizeStyle: WidgetBasicSizeStyle @@ -115,85 +117,96 @@ struct WidgetBasicView: View { } var body: some View { - ZStack(alignment: .leading) { - Rectangle().fill( - LinearGradient( - gradient: .init(colors: [.white.opacity(0.06), .black.opacity(0.06)]), - startPoint: .top, - endPoint: .bottom + switch widgetFamily { + case .accessoryCircular, .accessoryRectangular: + WidgetCircularView(icon: model.icon) + case .accessoryInline: + Label { + Text(model.title) + } icon: { + Image(uiImage: model.icon.image(ofSize: .init(width: 10, height: 10), color: .white)) + } + default: + ZStack(alignment: .leading) { + Rectangle().fill( + LinearGradient( + gradient: .init(colors: [.white.opacity(0.06), .black.opacity(0.06)]), + startPoint: .top, + endPoint: .bottom + ) ) - ) - - let text = Text(verbatim: model.title) - .font(sizeStyle.textFont) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - .foregroundColor(model.textColor) - .lineLimit(nil) - .minimumScaleFactor(0.5) - - let subtext: AnyView? = { - guard let subtitle = model.subtitle else { - return nil - } - return AnyView( - Text(verbatim: subtitle) - .font(sizeStyle.subtextFont) - .foregroundColor(model.textColor.opacity(0.7)) - .lineLimit(1) - .truncationMode(.middle) - ) - }() - - let icon = HStack(alignment: .top, spacing: -1) { - Text(verbatim: model.icon.unicode) - .font(sizeStyle.iconFont) - .minimumScaleFactor(0.2) - .foregroundColor(model.iconColor) - .fixedSize(horizontal: false, vertical: false) - - if model.showsChevron { - // this sfsymbols is a little more legible at smaller size than mdi:open-in-new - Image(systemName: "arrow.up.forward.app") - .font(sizeStyle.chevronFont) + let text = Text(verbatim: model.title) + .font(sizeStyle.textFont) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .foregroundColor(model.textColor) + .lineLimit(nil) + .minimumScaleFactor(0.5) + + let subtext: AnyView? = { + guard let subtitle = model.subtitle else { + return nil + } + + return AnyView( + Text(verbatim: subtitle) + .font(sizeStyle.subtextFont) + .foregroundColor(model.textColor.opacity(0.7)) + .lineLimit(1) + .truncationMode(.middle) + ) + }() + + let icon = HStack(alignment: .top, spacing: -1) { + Text(verbatim: model.icon.unicode) + .font(sizeStyle.iconFont) + .minimumScaleFactor(0.2) .foregroundColor(model.iconColor) + .fixedSize(horizontal: false, vertical: false) + + if model.showsChevron { + // this sfsymbols is a little more legible at smaller size than mdi:open-in-new + Image(systemName: "arrow.up.forward.app") + .font(sizeStyle.chevronFont) + .foregroundColor(model.iconColor) + } } - } - switch sizeStyle { - case .regular, .condensed: - HStack(alignment: .center, spacing: 6.0) { - icon - if let subtext { - VStack(alignment: .leading, spacing: -2) { + switch sizeStyle { + case .regular, .condensed: + HStack(alignment: .center, spacing: 6.0) { + icon + if let subtext { + VStack(alignment: .leading, spacing: -2) { + text + subtext + } + } else { text - subtext } - } else { + Spacer() + }.padding( + .leading, 12 + ) + case .single, .expanded: + VStack(alignment: .leading, spacing: 0) { + icon + Spacer() text + if let subtext { + subtext + } } - Spacer() - }.padding( - .leading, 12 - ) - case .single, .expanded: - VStack(alignment: .leading, spacing: 0) { - icon - Spacer() - text - if let subtext { - subtext - } + .padding( + [.leading, .trailing] + ).padding( + [.top, .bottom], + sizeStyle == .regular ? 10 : /* use default */ nil + ) } - .padding( - [.leading, .trailing] - ).padding( - [.top, .bottom], - sizeStyle == .regular ? 10 : /* use default */ nil - ) } + .background(model.backgroundColor) } - .background(model.backgroundColor) } } diff --git a/Sources/Extensions/Widgets/Common/WidgetCircularView.swift b/Sources/Extensions/Widgets/Common/WidgetCircularView.swift new file mode 100644 index 000000000..f49db1f3e --- /dev/null +++ b/Sources/Extensions/Widgets/Common/WidgetCircularView.swift @@ -0,0 +1,28 @@ +import Shared +import SwiftUI + +struct WidgetCircularView: View { + var icon: MaterialDesignIcons + + private static func scaleLogo(logo: UIImage, size: CGFloat) -> UIImage { + let canvas = CGSize(width: size, height: size) + let format = logo.imageRendererFormat + return UIGraphicsImageRenderer(size: canvas, format: format).image { + _ in logo.draw(in: CGRect(origin: .zero, size: canvas)) + } + } + + var body: some View { + VStack(spacing: 2) { + Image(uiImage: icon.image( + ofSize: .init(width: 24, height: 24), + color: .white + )) + .foregroundStyle(.ultraThickMaterial) + Image(uiImage: Self.scaleLogo(logo: Asset.SharedAssets.logo.image, size: 10)) + } + .padding() + .background(Color(uiColor: .secondarySystemBackground)) + .clipShape(Circle()) + } +} diff --git a/Sources/Extensions/Widgets/Details/WidgetDetails.swift b/Sources/Extensions/Widgets/Details/WidgetDetails.swift new file mode 100644 index 000000000..80253b0a1 --- /dev/null +++ b/Sources/Extensions/Widgets/Details/WidgetDetails.swift @@ -0,0 +1,44 @@ +import Intents +import Shared +import SwiftUI +import WidgetKit + +@available(iOS 17, *) +struct WidgetDetails: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: AppIntentWidgetKinds.details, + intent: WidgetDetailsAppIntent.self, + provider: WidgetDetailsAppIntentTimelineProvider() + ) { timelineEntry in + if timelineEntry.runAction, timelineEntry.action != nil { + Button(intent: intent(for: timelineEntry)) { + WidgetDetailsView(entry: timelineEntry) + .widgetBackground(Color.clear) + } + .buttonStyle(.plain) + } else { + WidgetDetailsView(entry: timelineEntry) + .widgetBackground(Color.clear) + } + } + .contentMarginsDisabledIfAvailable() + .configurationDisplayName(L10n.Widgets.Details.title) + .description(L10n.Widgets.Details.description) + .supportedFamilies(WidgetDetailsSupportedFamilies.families) + } + + private func intent(for entry: WidgetDetailsEntry) -> PerformAction { + let intent = PerformAction() + intent.action = IntentActionAppEntity(id: entry.action!.ID, displayString: entry.action!.Text) + return intent + } +} + +@available(iOS 17, *) +enum WidgetDetailsSupportedFamilies { + static let families: [WidgetFamily] = [ + .accessoryInline, + .accessoryRectangular, + ] +} diff --git a/Sources/Extensions/Widgets/Details/WidgetDetailsView.swift b/Sources/Extensions/Widgets/Details/WidgetDetailsView.swift new file mode 100644 index 000000000..1d76ccc73 --- /dev/null +++ b/Sources/Extensions/Widgets/Details/WidgetDetailsView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import WidgetKit + +@available(iOS 17.0, *) +struct WidgetDetailsView: View { + @Environment(\.widgetFamily) var family: WidgetFamily + + var entry: WidgetDetailsEntry + + var body: some View { + if family == .accessoryRectangular { + VStack(alignment: .leading) { + if entry.upperText != nil { + Text(entry.upperText!) + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.bold) + } else { + Text("Unknown upper") + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.bold) + .redacted(reason: .placeholder) + } + if entry.lowerText != nil { + Text(entry.lowerText!) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("Unknown lower") + .frame(maxWidth: .infinity, alignment: .leading) + .redacted(reason: .placeholder) + } + if entry.detailsText != nil { + Text(entry.detailsText!) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } else { + if entry.upperText != nil || entry.lowerText != nil { + Text((entry.upperText ?? "") + (entry.lowerText ?? "")) + } else { + Text("Unknown details") + .redacted(reason: .placeholder) + } + } + } +} diff --git a/Sources/Extensions/Widgets/Gauge/WidgetGauge.swift b/Sources/Extensions/Widgets/Gauge/WidgetGauge.swift new file mode 100644 index 000000000..dbee4c009 --- /dev/null +++ b/Sources/Extensions/Widgets/Gauge/WidgetGauge.swift @@ -0,0 +1,41 @@ +import Intents +import Shared +import SwiftUI +import WidgetKit + +@available(iOS 17, *) +struct WidgetGauge: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: AppIntentWidgetKinds.gauge, + intent: WidgetGaugeAppIntent.self, + provider: WidgetGaugeAppIntentTimelineProvider() + ) { timelineEntry in + if timelineEntry.runAction, timelineEntry.action != nil { + Button(intent: intent(for: timelineEntry)) { + WidgetGaugeView(entry: timelineEntry) + .widgetBackground(Color.clear) + } + .buttonStyle(.plain) + } else { + WidgetGaugeView(entry: timelineEntry) + .widgetBackground(Color.clear) + } + } + .contentMarginsDisabledIfAvailable() + .configurationDisplayName(L10n.Widgets.Gauge.title) + .description(L10n.Widgets.Gauge.description) + .supportedFamilies(WidgetGaugeSupportedFamilies.families) + } + + private func intent(for entry: WidgetGaugeEntry) -> PerformAction { + let intent = PerformAction() + intent.action = IntentActionAppEntity(id: entry.action!.ID, displayString: entry.action!.Text) + return intent + } +} + +@available(iOS 17, *) +enum WidgetGaugeSupportedFamilies { + static let families: [WidgetFamily] = [.accessoryCircular] +} diff --git a/Sources/Extensions/Widgets/Gauge/WidgetGaugeView.swift b/Sources/Extensions/Widgets/Gauge/WidgetGaugeView.swift new file mode 100644 index 000000000..fd4047472 --- /dev/null +++ b/Sources/Extensions/Widgets/Gauge/WidgetGaugeView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import WidgetKit + +@available(iOS 17.0, *) +struct WidgetGaugeView: View { + var entry: WidgetGaugeEntry + + var body: some View { + switch entry.gaugeType { + case .normal: + Gauge(value: entry.value) { + if entry.valueLabel != nil { + Text(entry.valueLabel!) + } else { + Text("00") + .redacted(reason: .placeholder) + } + } currentValueLabel: { + if entry.valueLabel != nil { + Text(entry.valueLabel!) + } else { + Text("00") + .redacted(reason: .placeholder) + } + } minimumValueLabel: { + if entry.min != nil { + Text(entry.min!) + } else { + Text("00") + .redacted(reason: .placeholder) + } + } maximumValueLabel: { + if entry.max != nil { + Text(entry.max!) + } else { + Text("00") + .redacted(reason: .placeholder) + } + } + .gaugeStyle(.accessoryCircular) + case .capacity: + Gauge(value: entry.value) { + if entry.valueLabel != nil { + Text(entry.valueLabel!) + } else { + Text("00") + .redacted(reason: .placeholder) + } + } currentValueLabel: { + if entry.valueLabel != nil { + Text(entry.valueLabel!) + } else { + Text("00") + .redacted(reason: .placeholder) + } + } + .gaugeStyle(.accessoryCircularCapacity) + } + } +} diff --git a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift index d0976b77e..2a0421b95 100644 --- a/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift +++ b/Sources/Extensions/Widgets/OpenPage/WidgetOpenPage.swift @@ -37,9 +37,19 @@ struct WidgetOpenPage: Widget { .contentMarginsDisabledIfAvailable() .configurationDisplayName(L10n.Widgets.OpenPage.title) .description(L10n.Widgets.OpenPage.description) - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge]) + .supportedFamilies(WidgetOpenPageSupportedFamilies.families) .onBackgroundURLSessionEvents(matching: nil) { identifier, completion in Current.webhooks.handleBackground(for: identifier, completionHandler: completion) } } } + +enum WidgetOpenPageSupportedFamilies { + static var families: [WidgetFamily] { + if #available(iOS 16.0, *) { + [.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryCircular] + } else { + [.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge] + } + } +} diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift index d2091e2ec..62788e113 100644 --- a/Sources/Extensions/Widgets/Widgets.swift +++ b/Sources/Extensions/Widgets/Widgets.swift @@ -7,6 +7,10 @@ struct Widgets: WidgetBundle { WidgetAssist() actionsWidget() WidgetOpenPage() + if #available(iOS 17, *) { + WidgetGauge() + WidgetDetails() + } } private func actionsWidget() -> some Widget { diff --git a/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift index f777ea2ad..1989e5b62 100644 --- a/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift @@ -59,12 +59,12 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { } let commandPayload: CommandPayload? = { - switch input["message"] as? String { - case "request_location_update", "request_location_updates": - return .init("request_location_update") - case "clear_badge": + switch LegacyNotificationCommandType(rawValue: input["message"] as? String ?? "") { + case .locationUpdate, .locationUpdates: + return .init(LegacyNotificationCommandType.locationUpdate.rawValue) + case .clearBadge: return .init(isAlert: true, payload: ["aps": ["badge": 0]]) - case "clear_notification": + case .clearNotification: var homeassistant = [String: Any]() if let tag = data["tag"] { @@ -75,9 +75,11 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { homeassistant["collapseId"] = collapseId } - return .init("clear_notification", homeassistant: homeassistant) - case "update_complications": - return .init("update_complications") + return .init(LegacyNotificationCommandType.clearNotification.rawValue, homeassistant: homeassistant) + case .updateComplications: + return .init(LegacyNotificationCommandType.updateComplications.rawValue) + case .updateWidgets: + return .init(LegacyNotificationCommandType.updateWidgets.rawValue) default: return nil } }() @@ -246,6 +248,15 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { } } +enum LegacyNotificationCommandType: String { + case locationUpdate = "request_location_update" + case locationUpdates = "request_location_updates" + case clearBadge = "clear_badge" + case clearNotification = "clear_notification" + case updateComplications = "update_complications" + case updateWidgets = "update_widgets" +} + private extension Dictionary where Value == Any { mutating func mutate( _ key: Key, diff --git a/Sources/Shared/Common/AppIntentWidgetKinds.swift b/Sources/Shared/Common/AppIntentWidgetKinds.swift new file mode 100644 index 000000000..8006498bb --- /dev/null +++ b/Sources/Shared/Common/AppIntentWidgetKinds.swift @@ -0,0 +1,4 @@ +public enum AppIntentWidgetKinds { + public static let gauge = "WidgetGauge" + public static let details = "WidgetDetails" +} diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 8d5219971..c400ad3ff 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -1,6 +1,7 @@ import Communicator import PromiseKit import UserNotifications +import WidgetKit public protocol NotificationCommandHandler { func handle(_ payload: [String: Any]) -> Promise @@ -19,9 +20,9 @@ public class NotificationCommandManager { public init() { register(command: "request_location_update", handler: HandlerLocationUpdate()) register(command: "clear_notification", handler: HandlerClearNotification()) - #if os(iOS) register(command: "update_complications", handler: HandlerUpdateComplications()) + register(command: "update_widgets", handler: HandlerUpdateWidgets()) #endif } @@ -112,4 +113,17 @@ private struct HandlerUpdateComplications: NotificationCommandHandler { } } } + +private struct HandlerUpdateWidgets: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + Current.Log.verbose("Reloading widgets triggered by notification command") + return Promise { seal in + DispatchQueue.main.async { + WidgetCenter.shared.reloadTimelines(ofKind: AppIntentWidgetKinds.gauge) + WidgetCenter.shared.reloadTimelines(ofKind: AppIntentWidgetKinds.details) + seal.fulfill(()) + } + } + } +} #endif diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index e44809f3d..ac1a544db 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -2857,6 +2857,10 @@ public enum L10n { public static var notConfigured: String { return L10n.tr("Localizable", "widgets.actions.not_configured") } /// Actions public static var title: String { return L10n.tr("Localizable", "widgets.actions.title") } + public enum Parameters { + /// Action + public static var action: String { return L10n.tr("Localizable", "widgets.actions.parameters.action") } + } } public enum Assist { /// Ask Assist @@ -2872,6 +2876,56 @@ public enum L10n { /// Reload all widgets public static var reloadTimeline: String { return L10n.tr("Localizable", "widgets.button.reload_timeline") } } + public enum Details { + /// Display states using from Home Assistant in text + public static var description: String { return L10n.tr("Localizable", "widgets.details.description") } + /// Details + public static var title: String { return L10n.tr("Localizable", "widgets.details.title") } + public enum Parameters { + /// Action + public static var action: String { return L10n.tr("Localizable", "widgets.details.parameters.action") } + /// Details Text Template (only in rectangular family) + public static var detailsTemplate: String { return L10n.tr("Localizable", "widgets.details.parameters.details_template") } + /// Lower Text Template + public static var lowerTemplate: String { return L10n.tr("Localizable", "widgets.details.parameters.lower_template") } + /// Run Action (only in rectangular family) + public static var runAction: String { return L10n.tr("Localizable", "widgets.details.parameters.run_action") } + /// Server + public static var server: String { return L10n.tr("Localizable", "widgets.details.parameters.server") } + /// Upper Text Template + public static var upperTemplate: String { return L10n.tr("Localizable", "widgets.details.parameters.upper_template") } + } + } + public enum Gauge { + /// Display numeric states from Home Assistant in a gauge + public static var description: String { return L10n.tr("Localizable", "widgets.gauge.description") } + /// Gauge + public static var title: String { return L10n.tr("Localizable", "widgets.gauge.title") } + public enum Parameters { + /// Action + public static var action: String { return L10n.tr("Localizable", "widgets.gauge.parameters.action") } + /// Gauge Type + public static var gaugeType: String { return L10n.tr("Localizable", "widgets.gauge.parameters.gauge_type") } + /// Max Label Template + public static var maxLabelTemplate: String { return L10n.tr("Localizable", "widgets.gauge.parameters.max_label_template") } + /// Min Label Template + public static var minLabelTemplate: String { return L10n.tr("Localizable", "widgets.gauge.parameters.min_label_template") } + /// Run Action + public static var runAction: String { return L10n.tr("Localizable", "widgets.gauge.parameters.run_action") } + /// Server + public static var server: String { return L10n.tr("Localizable", "widgets.gauge.parameters.server") } + /// Value Label Template + public static var valueLabelTemplate: String { return L10n.tr("Localizable", "widgets.gauge.parameters.value_label_template") } + /// Value Template (0-1) + public static var valueTemplate: String { return L10n.tr("Localizable", "widgets.gauge.parameters.value_template") } + public enum GaugeType { + /// Capacity + public static var capacity: String { return L10n.tr("Localizable", "widgets.gauge.parameters.gauge_type.capacity") } + /// Normal + public static var normal: String { return L10n.tr("Localizable", "widgets.gauge.parameters.gauge_type.normal") } + } + } + } public enum OpenPage { /// Open a frontend page in Home Assistant. public static var description: String { return L10n.tr("Localizable", "widgets.open_page.description") }