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