Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Lumen/Features/Home/HomeDashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ struct HomeDashboardView: View {
showStatusOverlay()
}
.onChange(of: locationService.isAtHome) { _, _ in
LumenHaptics.impact(.soft)
showStatusOverlay()
}
.onDisappear {
Expand Down
2 changes: 2 additions & 0 deletions Lumen/Features/Home/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
1 change: 1 addition & 0 deletions Lumen/Features/Home/LumenActionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ struct LumenActionView: View {
.padding(.vertical, 16)
.background(Color(hex: "#C49A6C"), in: RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.pressable)
.padding(.bottom, 10)
}

Expand Down
1 change: 1 addition & 0 deletions Lumen/Features/Scenes/SceneApprovalSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ struct SceneApprovalSheet: View {
.padding(.vertical, 16)
.background(Color(hex: "#C49A6C"), in: RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.pressable)
.padding(.bottom, 10)
}

Expand Down
10 changes: 8 additions & 2 deletions Lumen/Features/Scenes/SceneViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ final class SceneViewModel {
Task {
do {
try await sceneService.execute(scene)
LumenHaptics.success()
} catch {
self.error = error
LumenHaptics.error()
}
executingSceneID = nil
}
Expand All @@ -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(
Expand Down
49 changes: 49 additions & 0 deletions Lumen/Support/LumenHaptics.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
37 changes: 37 additions & 0 deletions Lumen/Support/PressableButtonStyle.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
26 changes: 26 additions & 0 deletions LumenTests/LumenHapticsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading