diff --git a/AI_Implementation.md b/AI_Implementation.md index 3ad2b78..ec7829b 100644 --- a/AI_Implementation.md +++ b/AI_Implementation.md @@ -302,6 +302,61 @@ The codebase is structured for future extraction into reusable packages: --- +## Known Issues / TODO + +### Camera Control Button Light Press - NOT WORKING + +**Status:** ❌ Broken - Needs Investigation + +The Camera Control button (iPhone 16+) **full press works** for photo capture, but the **light press (secondary action) does NOT work**. + +Testing revealed that the "secondary" events in logs were actually triggered by **volume button**, not Camera Control light press. The volume button works because `onCameraCaptureEvent` handles all hardware capture buttons. + +#### What Works: +- ✅ Camera Control full press → triggers photo capture +- ✅ Volume up/down → triggers secondary event (focus lock) + +#### What Doesn't Work: +- ❌ Camera Control light press → no event received at all +- ❌ Camera Control swipe gestures (zoom) → Apple-exclusive API + +#### User Action Required - Check Accessibility Settings: + +**Settings > Accessibility > Camera Control**: +- Ensure **Camera Control** is enabled +- Ensure **Light-Press** is turned ON +- Adjust **Light-Press Force** if needed +- Check **Double Light-Press Speed** + +These system settings may affect third-party apps differently than Apple Camera. + +#### Investigation Areas: + +1. **Accessibility settings may block third-party light press** + - User reports light press works in Apple Camera but not SelfieCam + - System may require explicit light-press enablement per-app + +2. **MijickCamera session configuration** + - The third-party camera framework may interfere with light press detection + - MijickCamera manages its own AVCaptureSession - may conflict + - Try testing with raw AVCaptureSession to isolate the issue + +3. **`onCameraCaptureEvent` secondaryAction limitations** + - The `secondaryAction` closure receives volume button events correctly + - Camera Control light press may use different event pathway + - Apple may internally route light press to their Camera app exclusively + +4. **Light press may require AVCapturePhotoOutput configuration** + - Secondary events might need specific photo output settings + - Check if `AVCapturePhotoSettings` has light-press related properties + +5. **Possible Apple restriction (most likely)** + - Light press and swipe gestures appear restricted to first-party apps + - Similar to swipe-to-zoom which is Apple-exclusive + - No public API documentation confirms light press availability + +--- + ## Future Enhancements Potential areas for expansion: @@ -312,6 +367,7 @@ Potential areas for expansion: - [ ] Apple Watch remote trigger - [ ] Export presets (aspect ratios, watermarks) - [ ] Social sharing integrations +- [ ] Camera Control button swipe-to-zoom (if Apple makes API public) --- diff --git a/SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift b/SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift index 80093ab..7a8584d 100644 --- a/SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift +++ b/SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift @@ -2,7 +2,7 @@ // CaptureEventInteraction.swift // SelfieCam // -// Handles hardware capture events using AVCaptureEventInteraction (iOS 17.2+). +// Handles hardware capture events using AVCaptureEventInteraction. // Supports Camera Control button (iPhone 16+), Action button, and volume buttons. // @@ -18,18 +18,20 @@ import UIKit /// - **Camera Control button** (iPhone 16+): Full press to capture, light press for focus/exposure /// - **Action button** (iPhone 15 Pro+): When configured for camera /// - **Volume buttons**: Hardware shutter via system integration -/// -/// - Note: Requires iOS 17.2+ for `AVCaptureEventInteraction` API @Observable @MainActor final class CaptureEventManager { private(set) var isActive = false private(set) var isFocusLocked = false - // Store as Any to avoid availability issues with the type declaration - private var eventInteraction: AnyObject? + private var eventInteraction: AVCaptureEventInteraction? private var onCapture: (() -> Void)? private var onFocusLock: ((Bool) -> Void)? + /// Pre-initialized haptic generators for immediate response + private let lightHaptic = UIImpactFeedbackGenerator(style: .light) + private let mediumHaptic = UIImpactFeedbackGenerator(style: .medium) + private let heavyHaptic = UIImpactFeedbackGenerator(style: .heavy) + /// Sets up capture event handling with the provided callbacks /// - Parameters: /// - onCapture: Called when a capture event is triggered (full press) @@ -42,19 +44,10 @@ final class CaptureEventManager { self.onFocusLock = onFocusLock } - /// Creates and returns the UIInteraction to be added to a view - /// - Returns: The configured interaction, or nil if not available - func createInteraction() -> (any UIInteraction)? { - guard #available(iOS 17.2, *) else { - Design.debugLog("CaptureEventInteraction: iOS 17.2+ required") - return nil - } + /// Creates and returns the AVCaptureEventInteraction to be added to a view + func createInteraction() -> AVCaptureEventInteraction { + Design.debugLog("📸 [CameraControl] Creating AVCaptureEventInteraction...") - return createCaptureEventInteraction() - } - - @available(iOS 17.2, *) - private func createCaptureEventInteraction() -> AVCaptureEventInteraction { let interaction = AVCaptureEventInteraction( primary: { [weak self] event in Task { @MainActor [weak self] in @@ -68,9 +61,16 @@ final class CaptureEventManager { } ) + // Must explicitly enable the interaction + interaction.isEnabled = true + + // Prepare haptic engines for immediate response + prepareHaptics() + self.eventInteraction = interaction self.isActive = true - Design.debugLog("CaptureEventInteraction: Hardware capture events enabled") + Design.debugLog("📸 [CameraControl] ✅ Hardware capture events ENABLED") + Design.debugLog("📸 [CameraControl] Supported: Full press (capture), Light press (focus lock)") return interaction } @@ -80,156 +80,82 @@ final class CaptureEventManager { eventInteraction = nil isActive = false isFocusLocked = false - Design.debugLog("CaptureEventInteraction: Hardware capture events disabled") + Design.debugLog("📸 [CameraControl] Hardware capture events disabled") } // MARK: - Event Handlers - @available(iOS 17.2, *) private func handlePrimaryEvent(_ event: AVCaptureEvent) { + Design.debugLog("📸 [CameraControl] PRIMARY - phase: \(event.phase.rawValue)") + switch event.phase { case .began: - Design.debugLog("CaptureEventInteraction: Primary event began (press detected)") - // Haptic feedback on press - triggerHaptic(.light) + Design.debugLog("📸 [CameraControl] PRIMARY BEGAN - Full press started") + triggerHaptic(.medium) case .ended: - Design.debugLog("CaptureEventInteraction: Primary event ended (capture)") + Design.debugLog("📸 [CameraControl] PRIMARY ENDED - Triggering capture!") + triggerHaptic(.heavy) onCapture?() case .cancelled: - Design.debugLog("CaptureEventInteraction: Primary event cancelled") + Design.debugLog("📸 [CameraControl] PRIMARY CANCELLED") @unknown default: - break + Design.debugLog("📸 [CameraControl] PRIMARY UNKNOWN phase: \(event.phase.rawValue)") } } - @available(iOS 17.2, *) private func handleSecondaryEvent(_ event: AVCaptureEvent) { + Design.debugLog("🔒 [CameraControl] SECONDARY - phase: \(event.phase.rawValue)") + switch event.phase { case .began: - Design.debugLog("CaptureEventInteraction: Secondary event began (focus lock)") + Design.debugLog("🔒 [CameraControl] SECONDARY BEGAN - Light press! Locking focus...") isFocusLocked = true onFocusLock?(true) - triggerHaptic(.light) + triggerHaptic(.medium) case .ended: - Design.debugLog("CaptureEventInteraction: Secondary event ended (focus unlock)") + Design.debugLog("🔒 [CameraControl] SECONDARY ENDED - Unlocking focus") isFocusLocked = false onFocusLock?(false) + triggerHaptic(.light) case .cancelled: - Design.debugLog("CaptureEventInteraction: Secondary event cancelled") + Design.debugLog("🔒 [CameraControl] SECONDARY CANCELLED") isFocusLocked = false onFocusLock?(false) @unknown default: - break + Design.debugLog("🔒 [CameraControl] SECONDARY UNKNOWN phase: \(event.phase.rawValue)") } } // MARK: - Haptic Feedback + /// Prepares haptic engines for low-latency feedback + func prepareHaptics() { + lightHaptic.prepare() + mediumHaptic.prepare() + heavyHaptic.prepare() + } + private func triggerHaptic(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { - let generator = UIImpactFeedbackGenerator(style: style) - generator.impactOccurred() - } -} - -// MARK: - SwiftUI View for Hosting the Interaction - -/// A SwiftUI view that hosts AVCaptureEventInteraction for Camera Control button support -/// -/// Add this view to your camera hierarchy to enable hardware capture events. -/// The view is invisible and passes through all touches. -/// -/// Usage: -/// ```swift -/// ZStack { -/// CameraPreview() -/// CaptureEventInteractionView( -/// onCapture: { performCapture() }, -/// onFocusLock: { locked in handleFocusLock(locked) } -/// ) -/// } -/// ``` -struct CaptureEventInteractionView: UIViewRepresentable { - /// Called when primary capture event occurs (full press) - let onCapture: () -> Void - - /// Called when focus/exposure lock state changes (light press) - var onFocusLock: ((Bool) -> Void)? - - func makeUIView(context: Context) -> CaptureEventHostView { - let view = CaptureEventHostView() - view.backgroundColor = .clear - view.isUserInteractionEnabled = true - - // Configure and add the interaction - context.coordinator.manager.configure( - onCapture: onCapture, - onFocusLock: onFocusLock - ) - - if let interaction = context.coordinator.manager.createInteraction() { - view.addInteraction(interaction) - context.coordinator.interaction = interaction + switch style { + case .light: + lightHaptic.impactOccurred() + lightHaptic.prepare() + case .medium: + mediumHaptic.impactOccurred() + mediumHaptic.prepare() + case .heavy: + heavyHaptic.impactOccurred() + heavyHaptic.prepare() + default: + let generator = UIImpactFeedbackGenerator(style: style) + generator.impactOccurred() } - - return view - } - - func updateUIView(_ uiView: CaptureEventHostView, context: Context) { - // Update callbacks if they change - context.coordinator.manager.configure( - onCapture: onCapture, - onFocusLock: onFocusLock - ) - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - static func dismantleUIView(_ uiView: CaptureEventHostView, coordinator: Coordinator) { - if let interaction = coordinator.interaction { - uiView.removeInteraction(interaction) - } - coordinator.manager.invalidate() - } - - @MainActor - class Coordinator { - let manager = CaptureEventManager() - var interaction: (any UIInteraction)? - } -} - -/// UIView subclass that hosts AVCaptureEventInteraction -/// -/// This view is transparent and allows touches to pass through while -/// still receiving hardware capture events. -final class CaptureEventHostView: UIView { - override init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - private func setup() { - backgroundColor = .clear - isUserInteractionEnabled = true - } - - // Allow touches to pass through to underlying views - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // Return nil to pass touches through, but the interaction still receives hardware events - return nil } } @@ -237,27 +163,27 @@ final class CaptureEventHostView: UIView { extension CaptureEventManager { /// Checks if the device supports Camera Control button + /// Note: The API is always available on iOS 18+, but the physical button is iPhone 16+ only static var isCameraControlSupported: Bool { - guard #available(iOS 17.2, *) else { return false } - - // Camera Control is available on iPhone 16 series - // We check for iOS 17.2+ as the API requirement - // The actual button is hardware-dependent return true } /// Returns a description of supported hardware capture methods static var supportedHardwareMethods: String { - var methods: [String] = [] - - if #available(iOS 17.2, *) { - methods.append("Camera Control (iPhone 16+)") - methods.append("Action Button") - methods.append("Volume Buttons") - } else { - methods.append("Volume Buttons (legacy)") - } - - return methods.joined(separator: ", ") + return "Camera Control (iPhone 16+), Action Button, Volume Buttons" + } +} + +// MARK: - AVCaptureEventPhase Extension + +extension AVCaptureEventPhase { + /// Converts AVCaptureEventPhase to our CaptureEventPhase enum + var toCaptureEventPhase: CaptureEventPhase { + switch self { + case .began: return .began + case .ended: return .ended + case .cancelled: return .cancelled + @unknown default: return .cancelled + } } } diff --git a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift index 625fc27..3347afd 100644 --- a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift +++ b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift @@ -6,6 +6,7 @@ // import AVFoundation +import AVKit import SwiftUI import Bedrock import MijickCamera @@ -52,6 +53,18 @@ struct CustomCameraScreen: MCameraScreen { createCameraOutputView() .ignoresSafeArea() .scaleEffect(x: cameraSettings.isMirrorFlipped ? -1 : 1, y: 1) // Apply horizontal mirror flip + // Camera Control button - applied directly to camera preview for best event handling + .onCameraCaptureEvent( + isEnabled: true, + primaryAction: { event in + Design.debugLog("📸 [onCameraCaptureEvent] primaryAction received!") + handlePrimaryCaptureEvent(event) + }, + secondaryAction: { event in + Design.debugLog("🔒 [onCameraCaptureEvent] secondaryAction received!") + handleSecondaryCaptureEvent(event) + } + ) // Pinch to zoom .gesture( MagnificationGesture() @@ -167,16 +180,6 @@ struct CustomCameraScreen: MCameraScreen { // Hidden volume view to prevent system HUD when using volume buttons as shutter HiddenVolumeView() .frame(width: 0, height: 0) - - // Camera Control button and hardware capture event support (iOS 17.2+) - CaptureEventInteractionView( - onCapture: { performCapture() }, - onFocusLock: { locked in - handleFocusLock(locked) - } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) } .animation(.easeInOut(duration: 0.05), value: isShowingScreenFlash) .animation(.easeInOut(duration: 0.3), value: isCountdownActive) @@ -422,16 +425,69 @@ struct CustomCameraScreen: MCameraScreen { } } + // MARK: - Camera Control Button Handlers + + /// Handles PRIMARY Camera Control event (full press - capture photo) + private func handlePrimaryCaptureEvent(_ event: AVCaptureEvent) { + Design.debugLog("📸 [CameraControl] PRIMARY - phase: \(event.phase.rawValue)") + + switch event.phase { + case .began: + Design.debugLog("📸 [CameraControl] PRIMARY BEGAN - Press detected") + triggerHaptic(.medium) + + case .ended: + Design.debugLog("📸 [CameraControl] PRIMARY ENDED - Capturing photo!") + triggerHaptic(.heavy) + performCapture() + + case .cancelled: + Design.debugLog("📸 [CameraControl] PRIMARY CANCELLED") + + @unknown default: + Design.debugLog("📸 [CameraControl] PRIMARY UNKNOWN phase") + } + } + + /// Handles SECONDARY Camera Control event (light press - focus/exposure lock) + private func handleSecondaryCaptureEvent(_ event: AVCaptureEvent) { + Design.debugLog("🔒 [CameraControl] SECONDARY - phase: \(event.phase.rawValue)") + + switch event.phase { + case .began: + Design.debugLog("🔒 [CameraControl] SECONDARY BEGAN - Light press! Locking focus...") + triggerHaptic(.medium) + handleFocusLock(true) + + case .ended: + Design.debugLog("🔒 [CameraControl] SECONDARY ENDED - Unlocking focus") + triggerHaptic(.light) + handleFocusLock(false) + + case .cancelled: + Design.debugLog("🔒 [CameraControl] SECONDARY CANCELLED") + handleFocusLock(false) + + @unknown default: + Design.debugLog("🔒 [CameraControl] SECONDARY UNKNOWN phase") + } + } + // MARK: - Focus/Exposure Lock (Camera Control Light Press) /// Handles focus/exposure lock from Camera Control button light press /// - Parameter locked: Whether focus/exposure is being locked private func handleFocusLock(_ locked: Bool) { + Design.debugLog("🔒 [FocusLock] handleFocusLock called with locked=\(locked)") + guard let device = AVCaptureDevice.default( .builtInWideAngleCamera, for: .video, position: cameraPosition == .front ? .front : .back - ) else { return } + ) else { + Design.debugLog("🔒 [FocusLock] ⚠️ No capture device found!") + return + } do { try device.lockForConfiguration() @@ -440,25 +496,31 @@ struct CustomCameraScreen: MCameraScreen { // Lock current focus and exposure if device.isFocusModeSupported(.locked) { device.focusMode = .locked + Design.debugLog("🔒 [FocusLock] ✅ Focus LOCKED") + } else { + Design.debugLog("🔒 [FocusLock] ⚠️ Focus lock not supported") } if device.isExposureModeSupported(.locked) { device.exposureMode = .locked + Design.debugLog("🔒 [FocusLock] ✅ Exposure LOCKED") + } else { + Design.debugLog("🔒 [FocusLock] ⚠️ Exposure lock not supported") } - Design.debugLog("Focus/Exposure locked via Camera Control") } else { // Restore continuous autofocus and auto exposure if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode = .continuousAutoFocus + Design.debugLog("🔒 [FocusLock] ✅ Focus UNLOCKED (continuous)") } if device.isExposureModeSupported(.continuousAutoExposure) { device.exposureMode = .continuousAutoExposure + Design.debugLog("🔒 [FocusLock] ✅ Exposure UNLOCKED (continuous)") } - Design.debugLog("Focus/Exposure unlocked via Camera Control") } device.unlockForConfiguration() } catch { - Design.debugLog("Failed to set focus/exposure lock: \(error)") + Design.debugLog("🔒 [FocusLock] ❌ Error: \(error)") } } diff --git a/SelfieCam/Features/Paywall/ProPaywallView.swift b/SelfieCam/Features/Paywall/ProPaywallView.swift index b5c35bf..53071d7 100644 --- a/SelfieCam/Features/Paywall/ProPaywallView.swift +++ b/SelfieCam/Features/Paywall/ProPaywallView.swift @@ -62,7 +62,7 @@ struct ProPaywallView: View { } .padding(Design.Spacing.large) } - .background(Color.Surface.overlay) + .background(AppSurface.overlay) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -102,11 +102,11 @@ private struct ProductPackageButton: View { } .frame(maxWidth: .infinity) .padding(Design.Spacing.large) - .background(Color.Accent.primary.opacity(Design.Opacity.medium)) + .background(AppAccent.primary.opacity(Design.Opacity.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .strokeBorder(Color.Accent.primary, lineWidth: Design.LineWidth.thin) + .strokeBorder(AppAccent.primary, lineWidth: Design.LineWidth.thin) ) } .accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")) @@ -123,7 +123,7 @@ struct BenefitRow: View { HStack(spacing: Design.Spacing.medium) { Image(systemName: image) .font(.title2) - .foregroundStyle(Color.Accent.primary) + .foregroundStyle(AppAccent.primary) .frame(width: Design.IconSize.xLarge) Text(text) diff --git a/SelfieCam/Features/Settings/ColorPresetButton.swift b/SelfieCam/Features/Settings/ColorPresetButton.swift index 6b614bd..281b4a7 100644 --- a/SelfieCam/Features/Settings/ColorPresetButton.swift +++ b/SelfieCam/Features/Settings/ColorPresetButton.swift @@ -31,7 +31,7 @@ struct ColorPresetButton: View { .overlay( Circle() .strokeBorder( - isSelected ? Color.Accent.primary : Color.Border.subtle, + isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin ) ) @@ -61,13 +61,13 @@ struct ColorPresetButton: View { if preset.isPremium { Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown") .font(.system(size: Design.BaseFontSize.xxSmall)) - .foregroundStyle(isPremiumUnlocked ? Color.Status.warning : Color.Status.warning.opacity(Design.Opacity.medium)) + .foregroundStyle(isPremiumUnlocked ? AppStatus.warning : AppStatus.warning.opacity(Design.Opacity.medium)) } } .padding(Design.Spacing.xSmall) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.small) - .fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear) + .fill(isSelected ? AppAccent.primary.opacity(Design.Opacity.subtle) : Color.clear) ) } .buttonStyle(.plain) diff --git a/SelfieCam/Features/Settings/CustomColorPickerButton.swift b/SelfieCam/Features/Settings/CustomColorPickerButton.swift index df2ad71..7d27ba6 100644 --- a/SelfieCam/Features/Settings/CustomColorPickerButton.swift +++ b/SelfieCam/Features/Settings/CustomColorPickerButton.swift @@ -36,7 +36,7 @@ struct CustomColorPickerButton: View { .overlay( Circle() .strokeBorder( - isSelected ? Color.Accent.primary : Color.Border.subtle, + isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin ) ) @@ -53,12 +53,12 @@ struct CustomColorPickerButton: View { Image(systemName: "crown.fill") .font(.system(size: Design.BaseFontSize.xxSmall)) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) } .padding(Design.Spacing.xSmall) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.small) - .fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear) + .fill(isSelected ? AppAccent.primary.opacity(Design.Opacity.subtle) : Color.clear) ) .accessibilityLabel(String(localized: "Custom color")) .accessibilityAddTraits(isSelected ? .isSelected : []) @@ -78,7 +78,7 @@ struct CustomColorPickerButton: View { .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) .overlay( Circle() - .strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin) + .strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin) ) // Lock overlay @@ -99,7 +99,7 @@ struct CustomColorPickerButton: View { Image(systemName: "crown") .font(.system(size: Design.BaseFontSize.xxSmall)) - .foregroundStyle(Color.Status.warning.opacity(Design.Opacity.medium)) + .foregroundStyle(AppStatus.warning.opacity(Design.Opacity.medium)) } .padding(Design.Spacing.xSmall) } diff --git a/SelfieCam/Features/Settings/LicensesView.swift b/SelfieCam/Features/Settings/LicensesView.swift index 368dcca..33a7397 100644 --- a/SelfieCam/Features/Settings/LicensesView.swift +++ b/SelfieCam/Features/Settings/LicensesView.swift @@ -32,7 +32,7 @@ struct LicensesView: View { } .padding(Design.Spacing.large) } - .background(Color.Surface.overlay) + .background(AppSurface.overlay) .navigationTitle(String(localized: "Open Source Licenses")) .navigationBarTitleDisplayMode(.inline) } @@ -50,7 +50,7 @@ struct LicensesView: View { HStack { Label(license, systemImage: "doc.text") .font(.system(size: Design.BaseFontSize.xSmall)) - .foregroundStyle(Color.Accent.primary) + .foregroundStyle(AppAccent.primary) Spacer() @@ -58,13 +58,13 @@ struct LicensesView: View { Link(destination: linkURL) { Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square") .font(.system(size: Design.BaseFontSize.xSmall)) - .foregroundStyle(Color.Accent.primary) + .foregroundStyle(AppAccent.primary) } } } .padding(.top, Design.Spacing.xSmall) } .padding(Design.Spacing.medium) - .background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) + .background(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) } } diff --git a/SelfieCam/Features/Settings/SettingsView.swift b/SelfieCam/Features/Settings/SettingsView.swift index 25d4679..368b519 100644 --- a/SelfieCam/Features/Settings/SettingsView.swift +++ b/SelfieCam/Features/Settings/SettingsView.swift @@ -28,124 +28,139 @@ struct SettingsView: View { // MARK: - Ring Light Section - SettingsSectionHeader(title: "Ring Light", systemImage: "light.max") + SettingsSectionHeader(title: "Ring Light", systemImage: "light.max", accentColor: AppAccent.primary) + + SettingsCard { + // Ring Light Enabled + SettingsToggle( + title: String(localized: "Enable Ring Light"), + subtitle: String(localized: "Show colored light ring around camera preview"), + isOn: $viewModel.isRingLightEnabled, + accentColor: AppAccent.primary + ) + .accessibilityHint(String(localized: "Enables or disables the ring light overlay")) - // Ring Light Enabled - SettingsToggle( - title: String(localized: "Enable Ring Light"), - subtitle: String(localized: "Show colored light ring around camera preview"), - isOn: $viewModel.isRingLightEnabled - ) - .accessibilityHint(String(localized: "Enables or disables the ring light overlay")) + // Ring Size Slider + ringSizeSlider - // Ring Size Slider - ringSizeSlider + // Color Preset + colorPresetSection - // Color Preset - colorPresetSection - - // Ring Light Brightness - ringLightBrightnessSlider + // Ring Light Brightness + ringLightBrightnessSlider + } // MARK: - Camera Controls Section - SettingsSectionHeader(title: "Camera Controls", systemImage: "camera") + SettingsSectionHeader(title: "Camera Controls", systemImage: "camera", accentColor: AppAccent.primary) + + SettingsCard { + // Camera Position + cameraPositionPicker - // Camera Position - cameraPositionPicker + // Flash Mode + flashModePicker - // Flash Mode - flashModePicker + // Flash Sync (premium) + premiumToggle( + title: String(localized: "Flash Sync"), + subtitle: String(localized: "Use ring light color for screen flash"), + isOn: $viewModel.isFlashSyncedWithRingLight, + accessibilityHint: String(localized: "Syncs flash color with ring light color") + ) - // Flash Sync (premium) - premiumToggle( - title: String(localized: "Flash Sync"), - subtitle: String(localized: "Use ring light color for screen flash"), - isOn: $viewModel.isFlashSyncedWithRingLight, - accessibilityHint: String(localized: "Syncs flash color with ring light color") - ) + // HDR Mode + hdrModePicker - // HDR Mode - hdrModePicker + // Center Stage (premium feature) + centerStageToggle - // Center Stage (premium feature) - centerStageToggle - - // Photo Quality - photoQualityPicker + // Photo Quality + photoQualityPicker + } // MARK: - Display Section - SettingsSectionHeader(title: "Display", systemImage: "eye") + SettingsSectionHeader(title: "Display", systemImage: "eye", accentColor: AppAccent.primary) + + SettingsCard { + // True Mirror (premium) + premiumToggle( + title: String(localized: "True Mirror"), + subtitle: String(localized: "Shows horizontally flipped preview like a real mirror"), + isOn: $viewModel.isMirrorFlipped, + accessibilityHint: String(localized: "Flips the camera preview horizontally") + ) - // True Mirror (premium) - premiumToggle( - title: String(localized: "True Mirror"), - subtitle: String(localized: "Shows horizontally flipped preview like a real mirror"), - isOn: $viewModel.isMirrorFlipped, - accessibilityHint: String(localized: "Flips the camera preview horizontally") - ) + SettingsToggle( + title: String(localized: "Grid Overlay"), + subtitle: String(localized: "Shows rule of thirds grid for composition"), + isOn: $viewModel.isGridVisible, + accentColor: AppAccent.primary + ) + .accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot")) - SettingsToggle( - title: String(localized: "Grid Overlay"), - subtitle: String(localized: "Shows rule of thirds grid for composition"), - isOn: $viewModel.isGridVisible - ) - .accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot")) - - // Skin Smoothing (premium) - premiumToggle( - title: String(localized: "Skin Smoothing"), - subtitle: String(localized: "Applies subtle real-time skin smoothing"), - isOn: $viewModel.isSkinSmoothingEnabled, - accessibilityHint: String(localized: "Applies light skin smoothing to the camera preview") - ) + // Skin Smoothing (premium) + premiumToggle( + title: String(localized: "Skin Smoothing"), + subtitle: String(localized: "Applies subtle real-time skin smoothing"), + isOn: $viewModel.isSkinSmoothingEnabled, + accessibilityHint: String(localized: "Applies light skin smoothing to the camera preview") + ) + } // MARK: - Capture Section - SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle") + SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle", accentColor: AppAccent.primary) + + SettingsCard { + // Timer Selection + timerPicker - // Timer Selection - timerPicker - - SettingsToggle( - title: String(localized: "Auto-Save"), - subtitle: String(localized: "Automatically save captures to Photo Library"), - isOn: $viewModel.isAutoSaveEnabled - ) - .accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture")) + SettingsToggle( + title: String(localized: "Auto-Save"), + subtitle: String(localized: "Automatically save captures to Photo Library"), + isOn: $viewModel.isAutoSaveEnabled, + accentColor: AppAccent.primary + ) + .accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture")) + } // MARK: - Pro Section - SettingsSectionHeader(title: "Pro", systemImage: "crown") + SettingsSectionHeader(title: "Pro", systemImage: "crown", accentColor: AppStatus.warning) proSection // MARK: - Sync Section - SettingsSectionHeader(title: String(localized: "iCloud Sync"), systemImage: "icloud") - - iCloudSyncSection + SettingsSectionHeader(title: String(localized: "iCloud Sync"), systemImage: "icloud", accentColor: AppAccent.primary) + + SettingsCard { + iCloudSyncSection + } // MARK: - About Section - SettingsSectionHeader(title: "About", systemImage: "info.circle") + SettingsSectionHeader(title: "About", systemImage: "info.circle", accentColor: AppAccent.primary) acknowledgmentsSection // MARK: - Debug Section #if DEBUG - SettingsSectionHeader(title: "Debug", systemImage: "ant.fill") + SettingsSectionHeader(title: "Debug", systemImage: "ant.fill", accentColor: AppStatus.error) - brandingDebugSection + SettingsCard { + brandingDebugSection + } #endif Spacer(minLength: Design.Spacing.xxxLarge) } .padding(.horizontal, Design.Spacing.large) } - .background(Color.Surface.overlay) + .background(AppSurface.primary) .navigationTitle(String(localized: "Settings")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -153,7 +168,7 @@ struct SettingsView: View { Button(String(localized: "Done")) { dismiss() } - .foregroundStyle(Color.Accent.primary) + .foregroundStyle(AppAccent.primary) } } } @@ -186,7 +201,7 @@ struct SettingsView: View { in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize, step: 5 ) - .tint(Color.Accent.primary) + .tint(AppAccent.primary) // Large ring icon Image(systemName: "circle") @@ -286,7 +301,7 @@ struct SettingsView: View { Image(systemName: "crown.fill") .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) } Text(String(localized: "High Dynamic Range for better lighting in photos")) @@ -319,7 +334,7 @@ struct SettingsView: View { Image(systemName: "crown.fill") .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) } Text(isPremiumUnlocked @@ -354,7 +369,7 @@ struct SettingsView: View { .frame(maxWidth: .infinity) .background( Capsule() - .fill(viewModel.photoQuality == quality ? Color.Accent.primary : Color.white.opacity(Design.Opacity.subtle)) + .fill(viewModel.photoQuality == quality ? AppAccent.primary : Color.white.opacity(Design.Opacity.subtle)) ) } .buttonStyle(.plain) @@ -415,7 +430,7 @@ struct SettingsView: View { in: 0.1...1.0, step: 0.05 ) - .tint(Color.Accent.primary) + .tint(AppAccent.primary) Image(systemName: "sun.max.fill") .font(.system(size: Design.BaseFontSize.large)) @@ -443,7 +458,7 @@ struct SettingsView: View { Image(systemName: "crown.fill") .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) } Text(String(localized: "Automatically keeps you centered in the frame")) @@ -451,7 +466,7 @@ struct SettingsView: View { .foregroundStyle(.white.opacity(Design.Opacity.medium)) } } - .tint(Color.Accent.primary) + .tint(AppAccent.primary) .padding(.vertical, Design.Spacing.xSmall) .disabled(!isPremiumUnlocked) .accessibilityLabel(String(localized: "Enable Center Stage")) @@ -477,7 +492,7 @@ struct SettingsView: View { Image(systemName: "crown.fill") .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) } Text(isPremiumUnlocked @@ -512,7 +527,7 @@ struct SettingsView: View { .frame(maxWidth: .infinity) .background( Capsule() - .fill(viewModel.selectedTimer == option ? Color.Accent.primary : Color.white.opacity(Design.Opacity.subtle)) + .fill(viewModel.selectedTimer == option ? AppAccent.primary : Color.white.opacity(Design.Opacity.subtle)) ) } .buttonStyle(.plain) @@ -536,7 +551,7 @@ struct SettingsView: View { HStack(spacing: Design.Spacing.medium) { Image(systemName: "crown.fill") .font(.title2) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { Text(String(localized: "Upgrade to Pro")) @@ -557,8 +572,8 @@ struct SettingsView: View { .padding(Design.Spacing.medium) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(Color.Accent.primary.opacity(Design.Opacity.subtle)) - .strokeBorder(Color.Accent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + .fill(AppAccent.primary.opacity(Design.Opacity.subtle)) + .strokeBorder(AppAccent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) ) } .buttonStyle(.plain) @@ -584,7 +599,7 @@ struct SettingsView: View { .foregroundStyle(.white.opacity(Design.Opacity.medium)) } } - .tint(Color.Accent.primary) + .tint(AppAccent.primary) .padding(.vertical, Design.Spacing.xSmall) .disabled(!viewModel.iCloudAvailable) .accessibilityHint(String(localized: "Syncs settings across all your devices via iCloud")) @@ -607,7 +622,7 @@ struct SettingsView: View { } label: { Text(String(localized: "Sync Now")) .font(.system(size: Design.BaseFontSize.caption, weight: .medium)) - .foregroundStyle(Color.Accent.primary) + .foregroundStyle(AppAccent.primary) } } .padding(.top, Design.Spacing.xSmall) @@ -626,9 +641,9 @@ struct SettingsView: View { private var syncStatusColor: Color { if !viewModel.hasCompletedInitialSync { - return Color.Status.warning + return AppStatus.warning } - return Color.Status.success + return AppStatus.success } private var syncStatusText: String { @@ -672,7 +687,7 @@ struct SettingsView: View { .foregroundStyle(.white.opacity(Design.Opacity.medium)) } .padding(Design.Spacing.medium) - .background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) + .background(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) } .buttonStyle(.plain) } @@ -703,7 +718,7 @@ struct SettingsView: View { Image(systemName: "crown.fill") .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) } Text(subtitle) @@ -711,7 +726,7 @@ struct SettingsView: View { .foregroundStyle(.white.opacity(Design.Opacity.medium)) } } - .tint(Color.Accent.primary) + .tint(AppAccent.primary) .padding(.vertical, Design.Spacing.xSmall) .disabled(!isPremiumUnlocked) .accessibilityHint(accessibilityHint) @@ -744,7 +759,7 @@ struct SettingsView: View { Image(systemName: "crown.fill") .font(.system(size: Design.BaseFontSize.small)) - .foregroundStyle(Color.Status.warning) + .foregroundStyle(AppStatus.warning) Spacer() } @@ -764,7 +779,7 @@ struct SettingsView: View { subtitle: "Unlock all premium features for testing", isOn: $viewModel.isDebugPremiumEnabled ) - .tint(Color.Status.warning) + .tint(AppStatus.warning) // Icon Generator NavigationLink { IconGeneratorView(config: .selfieCam, appName: "SelfieCam") @@ -787,7 +802,7 @@ struct SettingsView: View { .foregroundStyle(.white.opacity(Design.Opacity.medium)) } .padding(Design.Spacing.medium) - .background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) + .background(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) } .buttonStyle(.plain) @@ -817,7 +832,7 @@ struct SettingsView: View { .foregroundStyle(.white.opacity(Design.Opacity.medium)) } .padding(Design.Spacing.medium) - .background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) + .background(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium)) } .buttonStyle(.plain) } @@ -827,6 +842,29 @@ struct SettingsView: View { +// MARK: - Settings Card Container + +/// A card container that provides visual grouping for settings sections. +/// Uses the app's branded surface colors for separation from the background. +private struct SettingsCard: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + content + } + .padding(Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .fill(AppSurface.card) + ) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin) + ) + } +} + #Preview { SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false)) .preferredColorScheme(.dark) diff --git a/SelfieCam/Shared/SelfieCamTheme.swift b/SelfieCam/Shared/SelfieCamTheme.swift new file mode 100644 index 0000000..b0f0b19 --- /dev/null +++ b/SelfieCam/Shared/SelfieCamTheme.swift @@ -0,0 +1,144 @@ +// +// SelfieCamTheme.swift +// SelfieCam +// +// Custom color theme for SelfieCam matching the app's branding. +// Uses magenta/rose tinted surfaces with branded accent colors. +// + +import SwiftUI +import Bedrock + +// MARK: - SelfieCam Surface Colors + +/// Surface colors with a subtle rose/magenta tint to match app branding. +/// Provides more visual separation than neutral grays. +public enum SelfieCamSurfaceColors: SurfaceColorProvider { + /// Primary background - deep rose-tinted dark + public static let primary = Color(red: 0.08, green: 0.06, blue: 0.10) + + /// Secondary/elevated surface - slightly lighter with rose tint + public static let secondary = Color(red: 0.12, green: 0.08, blue: 0.14) + + /// Tertiary/card surface - more elevated + public static let tertiary = Color(red: 0.16, green: 0.11, blue: 0.18) + + /// Overlay background (for sheets/modals) - deep with subtle rose + public static let overlay = Color(red: 0.10, green: 0.07, blue: 0.12) + + /// Card/grouped element background - distinct from primary + public static let card = Color(red: 0.14, green: 0.10, blue: 0.16) + + /// Subtle fill for grouped content sections + public static let groupedFill = Color(red: 0.12, green: 0.09, blue: 0.14) + + /// Section fill for list sections - slightly more visible + public static let sectionFill = Color(red: 0.16, green: 0.12, blue: 0.18) +} + +// MARK: - SelfieCam Text Colors + +/// Text colors optimized for rose-tinted dark backgrounds. +public enum SelfieCamTextColors: TextColorProvider { + public static let primary = Color.white + public static let secondary = Color.white.opacity(Design.Opacity.accent) + public static let tertiary = Color.white.opacity(Design.Opacity.medium) + public static let disabled = Color.white.opacity(Design.Opacity.light) + public static let placeholder = Color.white.opacity(Design.Opacity.overlay) + public static let inverse = Color.black +} + +// MARK: - SelfieCam Accent Colors + +/// Accent colors derived from the app's branding magenta/rose. +public enum SelfieCamAccentColors: AccentColorProvider { + /// Primary accent - bright magenta/rose from branding + public static let primary = Color(red: 0.85, green: 0.25, blue: 0.45) + + /// Light variant - softer pink + public static let light = Color(red: 0.95, green: 0.45, blue: 0.60) + + /// Dark variant - deeper magenta + public static let dark = Color(red: 0.65, green: 0.18, blue: 0.35) + + /// Secondary accent - soft cream/warm white for contrast + public static let secondary = Color(red: 1.0, green: 0.95, blue: 0.90) +} + +// MARK: - SelfieCam Button Colors + +/// Button colors matching the branded theme. +public enum SelfieCamButtonColors: ButtonColorProvider { + public static let primaryLight = Color(red: 0.95, green: 0.40, blue: 0.55) + public static let primaryDark = Color(red: 0.75, green: 0.20, blue: 0.40) + public static let secondary = Color.white.opacity(Design.Opacity.subtle) + public static let destructive = Color.red.opacity(Design.Opacity.heavy) + public static let cancelText = Color.white.opacity(Design.Opacity.strong) +} + +// MARK: - SelfieCam Status Colors + +/// Standard semantic status colors. +public enum SelfieCamStatusColors: StatusColorProvider { + public static let success = Color(red: 0.2, green: 0.8, blue: 0.4) + public static let warning = Color(red: 1.0, green: 0.75, blue: 0.2) + public static let error = Color(red: 0.9, green: 0.3, blue: 0.3) + public static let info = Color(red: 0.5, green: 0.7, blue: 0.95) +} + +// MARK: - SelfieCam Border Colors + +/// Border colors for the rose-tinted theme. +public enum SelfieCamBorderColors: BorderColorProvider { + public static let subtle = Color.white.opacity(Design.Opacity.subtle) + public static let standard = Color.white.opacity(Design.Opacity.hint) + public static let emphasized = Color.white.opacity(Design.Opacity.light) + public static let selected = SelfieCamAccentColors.primary.opacity(Design.Opacity.medium) +} + +// MARK: - SelfieCam Interactive Colors + +/// Interactive state colors for the theme. +public enum SelfieCamInteractiveColors: InteractiveColorProvider { + public static let selected = SelfieCamAccentColors.primary.opacity(Design.Opacity.selection) + public static let hover = Color.white.opacity(Design.Opacity.subtle) + public static let pressed = Color.white.opacity(Design.Opacity.hint) + public static let focus = SelfieCamAccentColors.light +} + +// MARK: - SelfieCam Theme + +/// The complete SelfieCam color theme. +/// +/// Use this theme for consistent branded colors throughout the app: +/// ```swift +/// .background(SelfieCamTheme.Surface.primary) +/// .foregroundStyle(SelfieCamTheme.Accent.primary) +/// ``` +public enum SelfieCamTheme: AppColorTheme { + public typealias Surface = SelfieCamSurfaceColors + public typealias Text = SelfieCamTextColors + public typealias Accent = SelfieCamAccentColors + public typealias Button = SelfieCamButtonColors + public typealias Status = SelfieCamStatusColors + public typealias Border = SelfieCamBorderColors + public typealias Interactive = SelfieCamInteractiveColors +} + +// MARK: - Convenience Typealiases + +/// Short typealiases for cleaner usage throughout the app. +/// These avoid conflicts with Bedrock's default typealiases by using unique names. +/// +/// Usage: +/// ```swift +/// .background(AppSurface.primary) +/// .foregroundStyle(AppAccent.primary) +/// ``` +typealias AppSurface = SelfieCamSurfaceColors +typealias AppTextColors = SelfieCamTextColors +typealias AppAccent = SelfieCamAccentColors +typealias AppButtonColors = SelfieCamButtonColors +typealias AppStatus = SelfieCamStatusColors +typealias AppBorder = SelfieCamBorderColors +typealias AppInteractive = SelfieCamInteractiveColors