Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add action widgets, open page widgets, and gauges on the Lock Screen #2830

Merged
merged 32 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f718b01
Move the accessoryCircular button to a separate file
literally-anything Jun 27, 2024
f3b50ee
Add accessoryCircular family to the actions widget
literally-anything Jun 27, 2024
aff4022
Add accessoryCircular family to the open page widget
literally-anything Jun 27, 2024
55c727a
Merge branch 'home-assistant:master' into master
literally-anything Jun 27, 2024
5952a2e
Merge branch 'home-assistant:master' into master
literally-anything Jun 27, 2024
de5ed06
Add base gauge widget
literally-anything Jun 29, 2024
996ab5d
Add localization for the gauge widget
literally-anything Jun 29, 2024
a76099b
Add a push notification to reload the gauge widget
literally-anything Jun 29, 2024
cb1366e
Use server name instead of remote name for the server picker
literally-anything Jun 30, 2024
4b32660
Fix log message
literally-anything Jun 30, 2024
41ab442
Add placeholders and the details widget
literally-anything Jun 30, 2024
15bcc7c
Chnage the gauge and details identifiers
literally-anything Jun 30, 2024
9b53238
Run linter
literally-anything Jul 1, 2024
017a77c
Reorder min and max in the gauge widget app intent
literally-anything Jul 1, 2024
5be18f6
Move widget families into enums
literally-anything Jul 1, 2024
e59dcaf
Add comments for transparent widget families variable
literally-anything Jul 1, 2024
9c5b944
Merge branch 'master' into master
literally-anything Jul 2, 2024
def26b4
Add a legacy notification command for reloading widgets and move type…
literally-anything Jul 2, 2024
19e12d1
Fix the gauge and details widget kind
literally-anything Jul 2, 2024
abd90c0
Lower the widget reload time interval
literally-anything Jul 2, 2024
fbeac49
Add strings for gauge and details widget parameters
literally-anything Jul 2, 2024
b1b5d8c
Move widget kinds to a shared enum
literally-anything Jul 2, 2024
f1d1ec6
Merge branch 'master' into master
literally-anything Jul 2, 2024
d32bd63
Localize the parameters in the widget app intents
literally-anything Jul 2, 2024
adbc065
Localize the strings in the GagueType app enum
literally-anything Jul 2, 2024
dc6ebf7
Add english as a default when no localizaion exists
literally-anything Jul 3, 2024
8ca7e45
Update Sources/PushServer/SharedPush/Sources/NotificationParserLegacy…
literally-anything Jul 3, 2024
09692fe
Use the enum for the name of the command
literally-anything Jul 3, 2024
5533735
Merge branch 'master' into master
literally-anything Jul 4, 2024
cc1ae8f
Merge branch 'master' into master
literally-anything Jul 5, 2024
831e7f2
Make update_widgets only availible on iOS
literally-anything Jul 5, 2024
7555425
Merge remote-tracking branch 'refs/remotes/origin/master'
literally-anything Jul 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) }

Check warning on line 11 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L10-L11

Added lines #L10 - L11 were not covered by tests
}

func entities(matching string: String) async throws -> [IntentServerAppEntity] {
getServerEntities().filter { $0.getInfo()?.remoteName.contains(string) ?? false }

Check warning on line 15 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L14-L15

Added lines #L14 - L15 were not covered by tests
}

func suggestedEntities() async throws -> [IntentServerAppEntity] {
getServerEntities()

Check warning on line 19 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L18-L19

Added lines #L18 - L19 were not covered by tests
}

private func getServerEntities() -> [IntentServerAppEntity] {
Current.servers.all.map { IntentServerAppEntity(from: $0) }

Check warning on line 23 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L22-L23

Added lines #L22 - L23 were not covered by tests
}

func defaultResult() async -> IntentServerAppEntity? {
let server = Current.servers.all.first
if server == nil {
return nil
} else {
return IntentServerAppEntity(from: server!)

Check warning on line 31 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L26-L31

Added lines #L26 - L31 were not covered by tests
}
}
}

static let defaultQuery = IntentServerAppEntityQuery()

var id: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: .init(stringLiteral: getInfo()?.name ?? "Unknown")
)

Check warning on line 42 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L39-L42

Added lines #L39 - L42 were not covered by tests
}

init(identifier: Identifier<Server>) {
self.id = identifier.rawValue

Check warning on line 46 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L45-L46

Added lines #L45 - L46 were not covered by tests
}

init(from server: Server) {
self.init(identifier: server.identifier)

Check warning on line 50 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L49-L50

Added lines #L49 - L50 were not covered by tests
}

func getServer() -> Server? {
Current.servers.server(for: .init(rawValue: id))

Check warning on line 54 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L53-L54

Added lines #L53 - L54 were not covered by tests
}

func getInfo() -> ServerInfo? {
getServer()?.info

Check warning on line 58 in Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/IntentServerAppEntitiy.swift#L57-L58

Added lines #L57 - L58 were not covered by tests
}
}
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

Check warning on line 76 in Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift#L70-L76

Added lines #L70 - L76 were not covered by tests

\.$runAction
\.$action

Check warning on line 79 in Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift#L78-L79

Added lines #L78 - L79 were not covered by tests
}
} otherwise: {
Summary {
\.$server
\.$upperTemplate
\.$lowerTemplate
\.$detailsTemplate

Check warning on line 86 in Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift#L81-L86

Added lines #L81 - L86 were not covered by tests

\.$runAction

Check warning on line 88 in Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Extensions/AppIntents/Widget/Details/WidgetDetailsAppIntent.swift#L88

Added line #L88 was not covered by tests
}
}
}
}
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
Loading