Add branded SelfieCamTheme with visual improvements to settings

- Create SelfieCamTheme.swift with rose/magenta-tinted surface colors matching app branding
- Add App-prefixed typealiases (AppSurface, AppAccent, AppStatus, etc.) to avoid Bedrock conflicts
- Add SettingsCard container for visual grouping of related settings
- Update SettingsView with card containers and accent-colored section headers
- Update all settings-related files to use new theme colors
- Pro section uses warning color, Debug section uses error color for visual distinction
This commit is contained in:
Matt Bruce 2026-01-04 16:54:23 -06:00
parent 66736d39f9
commit 255f3c2d68
9 changed files with 499 additions and 273 deletions

View File

@ -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)
---

View File

@ -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
}
}
}

View File

@ -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)")
}
}

View File

@ -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)

View File

@ -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)

View File

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

View File

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

View File

@ -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)
// 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"))
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 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)
// Camera Position
cameraPositionPicker
SettingsCard {
// 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)
// 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")
)
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")
)
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"))
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"))
// 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)
// Timer Selection
timerPicker
SettingsCard {
// 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")
SettingsSectionHeader(title: String(localized: "iCloud Sync"), systemImage: "icloud", accentColor: AppAccent.primary)
iCloudSyncSection
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<Content: View>: 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)

View File

@ -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