-
Notifications
You must be signed in to change notification settings - Fork 315
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add action widgets, open page widgets, and gauges on the Lock Screen (#…
…2830) <!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary Allow action widget, open page widget, and watchOS style complications to be available on the iOS Lock Screen. ## Screenshots <img width="1163" alt="Screenshot 2024-06-30 at 9 28 19 AM" src="https://github.com/home-assistant/iOS/assets/62899372/be0fd76e-ae4a-494d-b564-9e467533087e"> <img width="722" alt="Screenshot 2024-06-30 at 11 17 13 AM" src="https://github.com/home-assistant/iOS/assets/62899372/d9ac98c8-acf4-4001-9a0c-461ba794c0ee"> <img width="677" alt="Screenshot 2024-06-30 at 9 29 26 AM" src="https://github.com/home-assistant/iOS/assets/62899372/6c2be522-dd78-4325-93e5-29e75ec20576"> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. --> --------- Co-authored-by: Bruno Pantaleão Gonçalves <[email protected]>
- Loading branch information
1 parent
e2db40d
commit e364507
Showing
23 changed files
with
1,147 additions
and
113 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
60 changes: 60 additions & 0 deletions
60
Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Server>) { | ||
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 | ||
} | ||
} |
92 changes: 92 additions & 0 deletions
92
Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
} | ||
} |
134 changes: 134 additions & 0 deletions
134
Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntentTimelineProvider.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Entry> { | ||
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<UnitDuration> { | ||
.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 | ||
} |
Oops, something went wrong.