Skip to content

Commit d951830

Browse files
authored
Add visual feedback for widget progress (#3403)
<!-- 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 <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ![CleanShot 2025-02-04 at 16 11 19@2x](https://github.com/user-attachments/assets/32261197-242c-441d-b727-5f476bf1a123) ## 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. -->
1 parent 44373c7 commit d951830

9 files changed

+210
-40
lines changed

Sources/Extensions/Widgets/Common/WidgetBasicButtonView.swift

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,39 +64,80 @@ struct WidgetBasicButtonView: WidgetBasicViewInterface {
6464
}
6565

6666
private var tileView: some View {
67-
VStack(alignment: .leading) {
68-
Group {
69-
switch sizeStyle {
70-
case .regular, .condensed, .compressed:
71-
HStack(alignment: .center, spacing: Spaces.oneAndHalf) {
72-
icon
73-
VStack(alignment: .leading, spacing: .zero) {
67+
ZStack(alignment: .topTrailing) {
68+
VStack(alignment: .leading) {
69+
Group {
70+
switch sizeStyle {
71+
case .regular, .condensed, .compressed:
72+
HStack(alignment: .center, spacing: Spaces.oneAndHalf) {
73+
icon
74+
VStack(alignment: .leading, spacing: .zero) {
75+
text
76+
subtext
77+
}
78+
.frame(maxWidth: .infinity, alignment: .leading)
79+
}
80+
.padding([.leading, .trailing], Spaces.oneAndHalf)
81+
case .single, .expanded:
82+
VStack(alignment: .leading, spacing: 0) {
83+
icon
84+
Spacer()
7485
text
7586
subtext
7687
}
7788
.frame(maxWidth: .infinity, alignment: .leading)
89+
.padding(.horizontal)
90+
.padding(.vertical, sizeStyle == .regular ? 10 : /* use default */ nil)
7891
}
79-
.padding([.leading, .trailing], Spaces.oneAndHalf)
80-
case .single, .expanded:
81-
VStack(alignment: .leading, spacing: 0) {
82-
icon
83-
Spacer()
84-
text
85-
subtext
86-
}
87-
.frame(maxWidth: .infinity, alignment: .leading)
88-
.padding(.horizontal)
89-
.padding(.vertical, sizeStyle == .regular ? 10 : /* use default */ nil)
9092
}
91-
}
92-
.modify { view in
93-
if #available(iOS 18, *) {
94-
view.widgetAccentable()
95-
} else {
96-
view
93+
.modify { view in
94+
if #available(iOS 18, *) {
95+
view.widgetAccentable()
96+
} else {
97+
view
98+
}
9799
}
98100
}
101+
.tileCardStyle(sizeStyle: sizeStyle, model: model, tinted: tinted)
102+
.opacity(model.disabled ? 0.3 : 1)
103+
progressIndicator
104+
}
105+
}
106+
107+
@ViewBuilder
108+
private var progressIndicator: some View {
109+
Group {
110+
let success = model.progress == 100
111+
let failure = model.progress == -1
112+
RingProgressView(progress: model.progress)
113+
.opacity((model.showProgress && !success && !failure) ? 1 : 0)
114+
.offset(x: -2, y: 2)
115+
Image(systemSymbol: success ? .checkmarkCircleFill : .xmarkCircleFill)
116+
.resizable()
117+
.frame(width: 19, height: 19, alignment: .topTrailing)
118+
.foregroundStyle(.white, success ? Color.asset(Asset.Colors.haPrimary) : .red)
119+
.opacity((success || failure) ? 1 : 0)
120+
}
121+
.padding([.top, .trailing], Spaces.one)
122+
}
123+
}
124+
125+
struct RingProgressView: View {
126+
var progress: Int
127+
128+
private let lineWidth: CGFloat = 4
129+
private let size: CGFloat = 15
130+
131+
var body: some View {
132+
ZStack {
133+
Circle()
134+
.trim(from: 0.0, to: CGFloat(progress) / 100)
135+
.stroke(
136+
AngularGradient(gradient: Gradient(colors: [Color.asset(Asset.Colors.haPrimary)]), center: .center),
137+
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
138+
)
139+
.rotationEffect(.degrees(-90))
99140
}
100-
.tileCardStyle(sizeStyle: sizeStyle, model: model, tinted: tinted)
141+
.frame(width: size, height: size, alignment: .topTrailing)
101142
}
102143
}

Sources/Extensions/Widgets/Common/WidgetBasicView.swift

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ struct WidgetBasicView: View {
1313
let rows: [[WidgetBasicViewModel]]
1414
let sizeStyle: WidgetBasicSizeStyle
1515

16-
private let opacityWhenDisabled: CGFloat = 0.3
17-
1816
var body: some View {
1917
let spacing = sizeStyle == .compressed ? .zero : Spaces.one
2018
VStack(alignment: .leading, spacing: spacing) {
@@ -308,7 +306,6 @@ struct WidgetBasicView: View {
308306
))
309307
}
310308
}
311-
.opacity(model.disabled ? opacityWhenDisabled : 1)
312309
}
313310

314311
private func normalView(model: WidgetBasicViewModel, sizeStyle: WidgetBasicSizeStyle) -> some View {
@@ -328,7 +325,6 @@ struct WidgetBasicView: View {
328325
))
329326
}
330327
}
331-
.opacity(model.disabled ? opacityWhenDisabled : 1)
332328
}
333329

334330
@available(iOS 17.0, *)
@@ -365,8 +361,10 @@ struct WidgetBasicView: View {
365361
return intent
366362
case .refresh:
367363
return ReloadWidgetsAppIntent()
368-
case let .toggle(entityId, domain, serverId):
364+
case let .toggle(widgetId, magicItemServerUniqueId, entityId, domain, serverId):
369365
let intent = CustomWidgetToggleAppIntent()
366+
intent.widgetId = widgetId
367+
intent.magicItemServerUniqueId = magicItemServerUniqueId
370368
intent.domain = domain
371369
intent.entityId = entityId
372370
intent.serverId = serverId

Sources/Extensions/Widgets/Common/WidgetBasicViewModel.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ struct WidgetBasicViewModel: Identifiable, Hashable, Encodable {
1414
backgroundColor: Color = Color.asset(Asset.Colors.tileBackground),
1515
useCustomColors: Bool = false,
1616
showConfirmation: Bool = false,
17+
showProgress: Bool = false,
18+
progress: Int = 0,
1719
requiresConfirmation: Bool = false,
1820
widgetId: String? = nil,
1921
disabled: Bool = false
@@ -28,6 +30,8 @@ struct WidgetBasicViewModel: Identifiable, Hashable, Encodable {
2830
self.backgroundColor = backgroundColor
2931
self.useCustomColors = useCustomColors
3032
self.showConfirmation = showConfirmation
33+
self.showProgress = showProgress
34+
self.progress = progress
3135
self.requiresConfirmation = requiresConfirmation
3236
self.widgetId = widgetId
3337
self.disabled = disabled
@@ -53,6 +57,10 @@ struct WidgetBasicViewModel: Identifiable, Hashable, Encodable {
5357
// the intent of the forms in this button will run or not the real intent
5458
var requiresConfirmation: Bool
5559

60+
// When the widget item is executing it can display progress
61+
var showProgress: Bool
62+
var progress: Int
63+
5664
/// Used to update confirmation state
5765
var widgetId: String?
5866
/// When one item confirmation is pending, the rest of the items should be blurred
@@ -67,7 +75,13 @@ struct WidgetBasicViewModel: Identifiable, Hashable, Encodable {
6775
case action(id: String, name: String)
6876
case script(id: String, entityId: String, serverId: String, name: String, showConfirmationNotification: Bool)
6977
/// Entities that can be toggled
70-
case toggle(entityId: String, domain: String, serverId: String)
78+
case toggle(
79+
widgetId: String,
80+
magicItemServerUniqueId: String,
81+
entityId: String,
82+
domain: String,
83+
serverId: String
84+
)
7185
/// Script or Scene
7286
case activate(entityId: String, domain: String, serverId: String)
7387
/// Button

Sources/Extensions/Widgets/Custom/AppIntents/CustomWidgetToggleAppIntent.swift

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ struct CustomWidgetToggleAppIntent: AppIntent {
88
static var title: LocalizedStringResource = "Toggle"
99
static var isDiscoverable: Bool = false
1010

11+
@Parameter(title: "Widget Id")
12+
var widgetId: String?
13+
@Parameter(title: "Magic Item Id")
14+
var magicItemServerUniqueId: String?
1115
@Parameter(title: "Server")
1216
var serverId: String?
1317
@Parameter(title: "Domain")
@@ -25,21 +29,70 @@ struct CustomWidgetToggleAppIntent: AppIntent {
2529
}), let connection = Current.api(for: server)?.connection else {
2630
return .result()
2731
}
28-
await withCheckedContinuation { continuation in
32+
33+
await updateProgress(waitSeconds: 0, progress: 20)
34+
let success = await withCheckedContinuation { continuation in
2935
connection.send(.toggleDomain(domain: domain, entityId: entityId)).promise.pipe { result in
3036
switch result {
3137
case .fulfilled:
32-
continuation.resume()
38+
continuation.resume(returning: true)
3339
case let .rejected(error):
3440
Current.Log
3541
.error(
3642
"Failed to execute ToggleAppIntent, serverId: \(serverId), domain: \(domain), entityId: \(entityId), error: \(error)"
3743
)
38-
continuation.resume()
44+
continuation.resume(returning: false)
3945
}
4046
}
4147
}
42-
_ = try await ResetAllCustomWidgetConfirmationAppIntent().perform()
48+
49+
if success {
50+
await updateProgress(waitSeconds: 1, progress: 80)
51+
await updateProgress(waitSeconds: 2, progress: 100)
52+
await resetStates(waitSeconds: 3)
53+
} else {
54+
await updateProgress(waitSeconds: 1, progress: -1)
55+
await resetStates(waitSeconds: 2)
56+
}
57+
4358
return .result()
4459
}
60+
61+
private func resetStates(waitSeconds: UInt64) async {
62+
Task {
63+
do {
64+
// Allow visual feedback to display
65+
await wait(seconds: 3)
66+
_ = try await ResetAllCustomWidgetConfirmationAppIntent().perform()
67+
} catch {
68+
Current.Log.error("Failed to ResetAllCustomWidgetConfirmationAppIntent")
69+
}
70+
}
71+
}
72+
73+
private func updateProgress(waitSeconds: UInt64, progress: Int) async {
74+
Task {
75+
do {
76+
// Update state to show progress
77+
let progressIntent = UpdateWidgetItemExecutionProgressStateAppIntent()
78+
progressIntent.widgetId = widgetId
79+
progressIntent.serverUniqueId = magicItemServerUniqueId
80+
progressIntent.progress = progress
81+
// Allow visual feedback to display
82+
await wait(seconds: waitSeconds)
83+
// Update state to show progress
84+
_ = try await progressIntent.perform()
85+
} catch {
86+
Current.Log.error("Failed to update custom widget item progress")
87+
}
88+
}
89+
}
90+
91+
private func wait(seconds: UInt64) async {
92+
do {
93+
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
94+
} catch {
95+
Current.Log.error("Failed to wait in Custom CustomWidgetToggleAppIntent")
96+
}
97+
}
4598
}

Sources/Extensions/Widgets/Custom/AppIntents/ResetAllCustomWidgetConfirmationAppIntent.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import AppIntents
22
import Foundation
33
import Shared
44
import SwiftUI
5+
import WidgetKit
56

67
@available(iOS 16.4, *)
78
struct ResetAllCustomWidgetConfirmationAppIntent: AppIntent {
89
static var title: LocalizedStringResource = "Reset custom widget confirmation states"
910
static var isDiscoverable: Bool = false
1011

1112
func perform() async throws -> some IntentResult {
13+
defer {
14+
WidgetCenter.shared.reloadTimelines(ofKind: WidgetsKind.custom.rawValue)
15+
}
1216
do {
1317
guard var customWidgets = try CustomWidget.widgets(), !customWidgets.isEmpty else {
1418
return .result()
@@ -37,7 +41,6 @@ struct ResetAllCustomWidgetConfirmationAppIntent: AppIntent {
3741
} catch {
3842
Current.Log.error("Failed to load custom widgets to reset items states: \(error)")
3943
}
40-
4144
return .result()
4245
}
4346
}

Sources/Extensions/Widgets/Custom/AppIntents/UpdateWidgetItemConfirmationStateAppIntent.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,45 @@ struct UpdateWidgetItemConfirmationStateAppIntent: AppIntent {
4141
return .result()
4242
}
4343
}
44+
45+
@available(iOS 16.4, *)
46+
struct UpdateWidgetItemExecutionProgressStateAppIntent: AppIntent {
47+
static var title: LocalizedStringResource = "Update custom widget confirmation"
48+
static var isDiscoverable: Bool = false
49+
50+
@Parameter(title: "Widget Id")
51+
var widgetId: String?
52+
53+
@Parameter(title: "item Id")
54+
var serverUniqueId: String?
55+
56+
@Parameter(title: "Progress")
57+
var progress: Int?
58+
59+
func perform() async throws -> some IntentResult {
60+
guard let serverUniqueId, let widgetId, let progress else {
61+
return .result()
62+
}
63+
64+
_ = try await ResetAllCustomWidgetConfirmationAppIntent().perform()
65+
66+
WidgetCenter.shared.reloadTimelines(ofKind: WidgetsKind.custom.rawValue)
67+
68+
if var widget = try CustomWidget.widgets()?.first(where: { $0.id == widgetId }),
69+
let magicItem = widget.items.first(where: { $0.serverUniqueId == serverUniqueId }) {
70+
widget.itemsStates[magicItem.serverUniqueId] = .progress(progress)
71+
do {
72+
try await Current.database.write { [widget] db in
73+
try widget.update(db)
74+
}
75+
WidgetCenter.shared.reloadTimelines(ofKind: WidgetsKind.custom.rawValue)
76+
} catch {
77+
Current.Log
78+
.error(
79+
"Failed to update custom widget to set pending confirmation item, error: \(error.localizedDescription)"
80+
)
81+
}
82+
}
83+
return .result()
84+
}
85+
}

0 commit comments

Comments
 (0)