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 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 } + ) + } +}