From 953d61a49173c2a7029a12e91db13ef6a51bd87b Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 15 Oct 2026 23:59:29 +0000 Subject: [PATCH 1/2] Hide a watch complication while the display is dimmed Adds a "Show when inactive" toggle to the complication builder's Customize sheet. When it is off, the complication shows the Home Assistant logo while the watch display is dimmed (wrist down, Always On) instead of its value. The setting already existed in the data model and is synced to the watch; nothing read it or let the user change it. Co-Authored-By: Claude Opus 5.5 --- .../Resources/en.lproj/Localizable.strings | 1 + .../WatchComplicationConfigurationSheet.swift | 21 ++++++++++ .../Shared/Resources/Swiftgen/Strings.swift | 2 + .../WatchWidgetComplicationSnapshot.swift | 6 +++ .../WatchWidgets/WatchWidgetsEntryView.swift | 16 ++++++-- ...ConfigurationSheetDimmedDisplay.test.swift | 39 +++++++++++++++++++ 6 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 Tests/App/Watch/WatchComplicationConfigurationSheetDimmedDisplay.test.swift diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 09f4a87166..7ce51eb1aa 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -2592,6 +2592,7 @@ The server only answers while the app is open, and stops when you leave it."; "watch.complications.builder.show_unit" = "Show unit"; "watch.complications.builder.show_value" = "Show value"; "watch.complications.builder.show_when_inactive" = "Show when inactive"; +"watch.complications.builder.show_when_inactive_footer" = "When off, the complication shows the Home Assistant logo while the watch display is dimmed, such as when your wrist is down."; "watch.complications.builder.size_options" = "%1$@ options"; "watch.complications.builder.size_options_footer" = "These settings apply to the selected size. Switch the preview size above to customize each size independently."; "watch.complications.builder.source" = "Source"; diff --git a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift index 4482da982e..99e5442e36 100644 --- a/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift +++ b/Sources/App/Settings/AppleWatch/Complications/WatchComplicationConfigurationSheet.swift @@ -22,6 +22,7 @@ struct WatchComplicationConfigurationSheet: View { entityValueSection familySection slotSections + dimmedDisplaySection } // Floats over the form only — never over the navigation bar, so the Done button stays // reachable. @@ -183,6 +184,17 @@ struct WatchComplicationConfigurationSheet: View { } } + /// Whether the complication keeps its content on the face while the watch display is dimmed (wrist + /// down, Always On). Global rather than per size, like the colors: it is a privacy choice about the + /// complication itself, so it sits after the per-size sections. Internal for its tests. + var dimmedDisplaySection: some View { + Section { + Toggle(isOn: showWhenInactiveBinding) { Text(L10n.Watch.Complications.Builder.showWhenInactive) } + } footer: { + Text(L10n.Watch.Complications.Builder.showWhenInactiveFooter) + } + } + /// Gauge + color options shown inside the value slot's section: everything else that shapes how /// the value element renders (the gauge/progress bar with its range and min/max labels, and the /// complication's colors). Keeping these with the value keeps each section self-contained, one @@ -502,6 +514,15 @@ struct WatchComplicationConfigurationSheet: View { ) } + /// Shown while dimmed unless turned off; nil (configs saved before the toggle existed) reads as on. + /// Internal for its tests. + var showWhenInactiveBinding: Binding { + Binding( + get: { viewModel.config.showsWhenInactive() }, + set: { viewModel.config.showWhenInactive = $0 } + ) + } + private func stringBinding(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { viewModel.config[keyPath: keyPath] ?? "" }, diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 326e370f43..a1d52f4508 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -8433,6 +8433,8 @@ public enum L10n { public static var showValue: String { return L10n.tr("Localizable", "watch.complications.builder.show_value") } /// Show when inactive public static var showWhenInactive: String { return L10n.tr("Localizable", "watch.complications.builder.show_when_inactive") } + /// When off, the complication shows the Home Assistant logo while the watch display is dimmed, such as when your wrist is down. + public static var showWhenInactiveFooter: String { return L10n.tr("Localizable", "watch.complications.builder.show_when_inactive_footer") } /// %1$@ options public static func sizeOptions(_ p1: Any) -> String { return L10n.tr("Localizable", "watch.complications.builder.size_options", String(describing: p1)) diff --git a/Sources/WatchWidgets/WatchWidgetComplicationSnapshot.swift b/Sources/WatchWidgets/WatchWidgetComplicationSnapshot.swift index e92182f6d0..9847eb9014 100644 --- a/Sources/WatchWidgets/WatchWidgetComplicationSnapshot.swift +++ b/Sources/WatchWidgets/WatchWidgetComplicationSnapshot.swift @@ -198,6 +198,12 @@ struct WatchWidgetComplicationSnapshot: Codable { /// Whether the complication should render while the display is dimmed (default true). var showsWhenInactive: Bool { showWhenInactive ?? true } + /// What the face draws for this complication: the built-in placeholder (the Home Assistant logo) + /// while the display is dimmed and the complication is set not to show then, otherwise itself. + func displayed(isLuminanceReduced: Bool) -> Self { + isLuminanceReduced && !showsWhenInactive ? .placeholder : self + } + // MARK: - Slot texts // The top-level fields predate slots and are confusingly inverted (`title` carries the value, diff --git a/Sources/WatchWidgets/WatchWidgetsEntryView.swift b/Sources/WatchWidgets/WatchWidgetsEntryView.swift index 2acc2f66fc..db041ddd33 100644 --- a/Sources/WatchWidgets/WatchWidgetsEntryView.swift +++ b/Sources/WatchWidgets/WatchWidgetsEntryView.swift @@ -6,6 +6,14 @@ import WidgetKit @available(watchOS 10.0, *) struct WatchWidgetsEntryView: View { let entry: WatchWidgetEntry + /// True while the display is dimmed: wrist down on an Always On watch. + @Environment(\.isLuminanceReduced) private var isLuminanceReduced + + /// The complication as the face should draw it right now: swapped for the Home Assistant logo while + /// dimmed if the user turned "Show when inactive" off. The tap target stays the real complication's. + private var complication: WatchWidgetComplicationSnapshot? { + entry.complication?.displayed(isLuminanceReduced: isLuminanceReduced) + } var body: some View { content @@ -31,13 +39,13 @@ struct WatchWidgetsEntryView: View { private var content: some View { switch entry.family { case .accessoryInline: - InlineComplicationView(complication: entry.complication, family: entry.family) + InlineComplicationView(complication: complication, family: entry.family) case .accessoryCorner: - CornerComplicationView(complication: entry.complication, family: entry.family) + CornerComplicationView(complication: complication, family: entry.family) case .accessoryRectangular: - RectangularComplicationView(complication: entry.complication, family: entry.family) + RectangularComplicationView(complication: complication, family: entry.family) default: - CircularComplicationView(complication: entry.complication, family: entry.family) + CircularComplicationView(complication: complication, family: entry.family) } } } diff --git a/Tests/App/Watch/WatchComplicationConfigurationSheetDimmedDisplay.test.swift b/Tests/App/Watch/WatchComplicationConfigurationSheetDimmedDisplay.test.swift new file mode 100644 index 0000000000..233b2d013d --- /dev/null +++ b/Tests/App/Watch/WatchComplicationConfigurationSheetDimmedDisplay.test.swift @@ -0,0 +1,39 @@ +@testable import HomeAssistant +@testable import Shared +import SharedTesting +import SwiftUI +import Testing + +/// The "Show when inactive" choice in the complication's Customize sheet: what the toggle reads for a +/// config saved before it existed, that flipping it is what the watch later reads, and how the section +/// looks. The watch-side hiding lives in the widget extension, which no unit-test target builds. +@MainActor +struct WatchComplicationConfigurationSheetDimmedDisplayTests { + @Test func showWhenInactiveReadsOnUntilTurnedOff() { + let viewModel = WatchComplicationBuilderEditViewModel( + existing: WatchComplicationConfig(serverId: "server-1", entityId: "sensor.net_worth") + ) + let sheet = WatchComplicationConfigurationSheet(viewModel: viewModel) + + // Saved before the toggle existed: nothing stored, which must still mean "shown". + #expect(viewModel.config.showWhenInactive == nil) + #expect(sheet.showWhenInactiveBinding.wrappedValue) + + sheet.showWhenInactiveBinding.wrappedValue = false + #expect(viewModel.config.showWhenInactive == false) + #expect(viewModel.config.showsWhenInactive() == false) + #expect(!sheet.showWhenInactiveBinding.wrappedValue) + + sheet.showWhenInactiveBinding.wrappedValue = true + #expect(viewModel.config.showsWhenInactive()) + } + + @Test func dimmedDisplaySection() { + let viewModel = WatchComplicationBuilderEditViewModel( + existing: WatchComplicationConfig(serverId: "server-1", entityId: "sensor.net_worth") + ) + assertLightDarkSnapshots( + of: Form { WatchComplicationConfigurationSheet(viewModel: viewModel).dimmedDisplaySection } + ) + } +} From fda09d19f54eac6a0cb47a3f0a90e64d50f79d70 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Fri, 16 Oct 2026 00:02:32 +0000 Subject: [PATCH 2/2] Fork only: upload recorded snapshots --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e54804a7..df8091e196 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,6 +214,16 @@ jobs: - name: Run tests run: bundle exec fastlane test + # Fork-only: hand back the snapshot references the run recorded or changed. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + name: "Upload recorded snapshots" + if: ${{ always() }} + with: + name: recorded-snapshots + path: | + Tests/**/__Snapshots__/** + if-no-files-found: ignore + # Save the warmed DerivedData only from `main`, and only on a fresh key, # so PR runs never evict the shared base cache other PRs restore from. - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0