From 120610b974bcd21f74e21bbb5f073fd9e133249d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:21:13 +0000 Subject: [PATCH] Add tactile consent feedback to the iOS app (haptics + press style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lumen's identity is consent-before-action and calm, Apple-tier polish, but the app had no haptic feedback anywhere and no press feedback on buttons. This adds a small, centralized tactile layer at the moments that matter. New (Lumen/Support/): - LumenHaptics: a @MainActor enum that is the single place that speaks to the Taptic Engine — success / warning / error / selection / impact. All feedback is suppressed under XCTest so headless unit runs stay silent. - PressableButtonStyle: a `.buttonStyle(.pressable)` that eases the label down and dims it while pressed, honoring Reduce Motion (drops the scale). Wired into the consent path (view models are the single source of truth for user-initiated actions, so automated geofence/schedule runs stay silent): - SceneViewModel.execute: success/error haptic on scene run. - SceneViewModel.toggleFavorite: selection tick. - HomeViewModel.executeScene: success/error haptic on the dashboard apply. - HomeDashboardView: soft bump only on an actual arrival/departure change. - SceneApprovalSheet + LumenActionView "Apply" CTAs: `.pressable` press feel. Tests (LumenTests/LumenHapticsTests.swift): assert the XCTest guard disables haptics and that every entry point is a safe no-op while disabled. Note: this environment is Linux, so the iOS target could not be compiled or run here — changes were made by reasoning about the source and follow the repo's existing patterns (synchronized-folder targets, pure/testable helpers, the XCTest env guard used in RootView). Build and run in Xcode to verify feel. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PE4HFdLKjK4er6BBAgbgrb --- Lumen/Features/Home/HomeDashboardView.swift | 1 + Lumen/Features/Home/HomeViewModel.swift | 2 + Lumen/Features/Home/LumenActionView.swift | 1 + .../Features/Scenes/SceneApprovalSheet.swift | 1 + Lumen/Features/Scenes/SceneViewModel.swift | 10 +++- Lumen/Support/LumenHaptics.swift | 49 +++++++++++++++++++ Lumen/Support/PressableButtonStyle.swift | 37 ++++++++++++++ LumenTests/LumenHapticsTests.swift | 26 ++++++++++ 8 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 Lumen/Support/LumenHaptics.swift create mode 100644 Lumen/Support/PressableButtonStyle.swift create mode 100644 LumenTests/LumenHapticsTests.swift diff --git a/Lumen/Features/Home/HomeDashboardView.swift b/Lumen/Features/Home/HomeDashboardView.swift index 524bb9c..1c8f2d1 100644 --- a/Lumen/Features/Home/HomeDashboardView.swift +++ b/Lumen/Features/Home/HomeDashboardView.swift @@ -89,6 +89,7 @@ struct HomeDashboardView: View { showStatusOverlay() } .onChange(of: locationService.isAtHome) { _, _ in + LumenHaptics.impact(.soft) showStatusOverlay() } .onDisappear { diff --git a/Lumen/Features/Home/HomeViewModel.swift b/Lumen/Features/Home/HomeViewModel.swift index c01e70a..20f0d76 100644 --- a/Lumen/Features/Home/HomeViewModel.swift +++ b/Lumen/Features/Home/HomeViewModel.swift @@ -76,8 +76,10 @@ final class HomeViewModel { guard let sceneService = sceneService else { return } do { try await sceneService.execute(scene) + LumenHaptics.success() } catch { self.error = error + LumenHaptics.error() } } diff --git a/Lumen/Features/Home/LumenActionView.swift b/Lumen/Features/Home/LumenActionView.swift index 7a32077..330c681 100644 --- a/Lumen/Features/Home/LumenActionView.swift +++ b/Lumen/Features/Home/LumenActionView.swift @@ -97,6 +97,7 @@ struct LumenActionView: View { .padding(.vertical, 16) .background(Color(hex: "#C49A6C"), in: RoundedRectangle(cornerRadius: 18)) } + .buttonStyle(.pressable) .padding(.bottom, 10) } diff --git a/Lumen/Features/Scenes/SceneApprovalSheet.swift b/Lumen/Features/Scenes/SceneApprovalSheet.swift index 317c1f9..31609a1 100644 --- a/Lumen/Features/Scenes/SceneApprovalSheet.swift +++ b/Lumen/Features/Scenes/SceneApprovalSheet.swift @@ -97,6 +97,7 @@ struct SceneApprovalSheet: View { .padding(.vertical, 16) .background(Color(hex: "#C49A6C"), in: RoundedRectangle(cornerRadius: 18)) } + .buttonStyle(.pressable) .padding(.bottom, 10) } diff --git a/Lumen/Features/Scenes/SceneViewModel.swift b/Lumen/Features/Scenes/SceneViewModel.swift index 398873f..ddb9fc5 100644 --- a/Lumen/Features/Scenes/SceneViewModel.swift +++ b/Lumen/Features/Scenes/SceneViewModel.swift @@ -56,8 +56,10 @@ final class SceneViewModel { Task { do { try await sceneService.execute(scene) + LumenHaptics.success() } catch { self.error = error + LumenHaptics.error() } executingSceneID = nil } @@ -69,8 +71,12 @@ final class SceneViewModel { } func toggleFavorite(_ scene: Scene) { - do { try sceneService.updateScene(scene, isFavorite: !scene.isFavorite) } - catch { self.error = error } + do { + try sceneService.updateScene(scene, isFavorite: !scene.isFavorite) + LumenHaptics.selection() + } catch { + self.error = error + } } func updateScene( diff --git a/Lumen/Support/LumenHaptics.swift b/Lumen/Support/LumenHaptics.swift new file mode 100644 index 0000000..05ec67f --- /dev/null +++ b/Lumen/Support/LumenHaptics.swift @@ -0,0 +1,49 @@ +import UIKit + +// MARK: - Lumen Haptics +// Centralized, calm haptic feedback for Lumen's consent moments. +// +// Lumen is consent-before-action: a soft, deliberate tap is what tells you that +// *you* approved something. This enum is the single place that speaks to the +// Taptic Engine, so the feel stays consistent everywhere — a gentle success +// when a scene runs, a selection tick when you favorite one, a soft bump when +// an arrival/departure banner appears. +// +// All feedback is suppressed under XCTest so headless unit runs stay silent and +// never touch UIKit's feedback generators. + +@MainActor +enum LumenHaptics { + + /// A scene ran or a suggested action was applied — a gentle success tap. + static func success() { notify(.success) } + + /// A recoverable hiccup the user should notice. + static func warning() { notify(.warning) } + + /// An action failed. + static func error() { notify(.error) } + + /// A discrete value or toggle changed (e.g. favoriting a scene). + static func selection() { + guard isEnabled else { return } + UISelectionFeedbackGenerator().selectionChanged() + } + + /// A light physical bump, e.g. when an ambient status banner appears. + static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .light) { + guard isEnabled else { return } + UIImpactFeedbackGenerator(style: style).impactOccurred() + } + + private static func notify(_ type: UINotificationFeedbackGenerator.FeedbackType) { + guard isEnabled else { return } + UINotificationFeedbackGenerator().notificationOccurred(type) + } + + /// Haptics are disabled under XCTest so the unit suite stays silent. + /// Exposed for testing the guard directly. + static var isEnabled: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil + } +} diff --git a/Lumen/Support/PressableButtonStyle.swift b/Lumen/Support/PressableButtonStyle.swift new file mode 100644 index 0000000..78cd60b --- /dev/null +++ b/Lumen/Support/PressableButtonStyle.swift @@ -0,0 +1,37 @@ +import SwiftUI + +// MARK: - Pressable Button Style +// A calm, tactile button style: the label eases down slightly and dims while +// pressed, giving immediate physical feedback on tap — the kind of small, +// responsive detail that separates an Apple-tier feel from a flat control. +// +// Honors Reduce Motion by dropping the scale (keeping only the gentle dim), and +// pairs naturally with LumenHaptics at the semantic moment the action fires. +// +// Usage: `.buttonStyle(.pressable)` + +struct PressableButtonStyle: ButtonStyle { + var scale: CGFloat = 0.97 + + func makeBody(configuration: Configuration) -> some View { + PressableButtonBody(configuration: configuration, scale: scale) + } + + private struct PressableButtonBody: View { + let configuration: ButtonStyleConfiguration + let scale: CGFloat + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + configuration.label + .scaleEffect(reduceMotion ? 1 : (configuration.isPressed ? scale : 1)) + .opacity(configuration.isPressed ? 0.9 : 1) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } + } +} + +extension ButtonStyle where Self == PressableButtonStyle { + /// Tactile press feedback for primary actions: `.buttonStyle(.pressable)`. + static var pressable: PressableButtonStyle { PressableButtonStyle() } +} diff --git a/LumenTests/LumenHapticsTests.swift b/LumenTests/LumenHapticsTests.swift new file mode 100644 index 0000000..fd992e6 --- /dev/null +++ b/LumenTests/LumenHapticsTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import Lumen + +// MARK: - LumenHaptics Tests +// Verifies the XCTest guard so the unit suite never fires real haptics, and +// that every entry point is a safe no-op while disabled. + +@MainActor +final class LumenHapticsTests: XCTestCase { + + func testHapticsAreDisabledUnderXCTest() { + // The unit suite always runs with XCTestConfigurationFilePath set, so + // the Taptic Engine is never touched during tests. + XCTAssertFalse(LumenHaptics.isEnabled) + } + + func testFeedbackEntryPointsAreNoOpWhileDisabled() { + // Each call should return without touching UIKit's generators. + LumenHaptics.success() + LumenHaptics.warning() + LumenHaptics.error() + LumenHaptics.selection() + LumenHaptics.impact() + LumenHaptics.impact(.soft) + } +}