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
## 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") }