Skip to content

Commit

Permalink
Add action widgets, open page widgets, and gauges on the Lock Screen (#…
Browse files Browse the repository at this point in the history
…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 <bruno.ing879@gmail.com>
  • Loading branch information
literally-anything and bgoncal authored Jul 8, 2024
1 parent e2db40d commit e364507
Show file tree
Hide file tree
Showing 23 changed files with 1,147 additions and 113 deletions.
102 changes: 90 additions & 12 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
60 changes: 60 additions & 0 deletions Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift
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
}
}
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
}
}
}
}
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
}
Loading

0 comments on commit e364507

Please sign in to comment.