Compare commits

..

4 Commits

Author SHA1 Message Date
255f3c2d68 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
2026-01-04 16:54:23 -06:00
66736d39f9 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-04 16:21:41 -06:00
f7c554d018 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-04 16:04:18 -06:00
fe7ab135af Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-04 16:03:50 -06:00
17 changed files with 876 additions and 202 deletions

View File

@ -522,6 +522,7 @@ SelfieCam uses the following architectural patterns:
| `RingLightConfigurable` | Ring light settings (size, color, opacity) | `SettingsViewModel` | | `RingLightConfigurable` | Ring light settings (size, color, opacity) | `SettingsViewModel` |
| `CaptureControlling` | Capture actions (timer, flash, shutter) | `SettingsViewModel` | | `CaptureControlling` | Capture actions (timer, flash, shutter) | `SettingsViewModel` |
| `PremiumManaging` | Subscription state and purchases | `PremiumManager` | | `PremiumManaging` | Subscription state and purchases | `PremiumManager` |
| `CaptureEventHandling` | Hardware button capture events (Camera Control, Action button) | `CaptureEventManager` |
## Premium Features ## Premium Features
@ -553,7 +554,6 @@ SelfieCam uses the following architectural patterns:
- Skin smoothing - Skin smoothing
- Center Stage - Center Stage
- Extended timers (5s, 10s) - Extended timers (5s, 10s)
- Video and Boomerang capture modes
## Settings & iCloud Sync ## Settings & iCloud Sync
@ -621,7 +621,6 @@ var flashMode: CameraFlashMode // .off, .on, .auto
- Front/back camera switching - Front/back camera switching
- Pinch-to-zoom - Pinch-to-zoom
- Photo capture with quality settings - Photo capture with quality settings
- Video recording (premium)
- HDR mode (premium) - HDR mode (premium)

View File

@ -75,7 +75,8 @@ Features/
- Post-capture preview with share functionality - Post-capture preview with share functionality
- Auto-save option to Photo Library - Auto-save option to Photo Library
- Front flash using screen brightness - Front flash using screen brightness
- Support for photo, video, and boomerang modes - **Camera Control button** (iPhone 16+): Full press captures, light press locks focus/exposure
- **Hardware shutter**: Volume buttons trigger capture via `VolumeButtonObserver`
### 4. Freemium Model ### 4. Freemium Model
- Built with **RevenueCat** for subscription management - Built with **RevenueCat** for subscription management
@ -99,6 +100,52 @@ Features/
--- ---
## Camera Control Button Integration
### Overview
The app supports the **Camera Control** button on iPhone 16+ via `AVCaptureEventInteraction` (iOS 17.2+).
### Files Involved
| File | Purpose |
|------|---------|
| `Shared/Protocols/CaptureEventHandling.swift` | Protocol defining hardware capture event handling |
| `Features/Camera/Views/CaptureEventInteraction.swift` | `AVCaptureEventInteraction` wrapper and SwiftUI integration |
| `Features/Camera/Views/VolumeButtonObserver.swift` | Volume button capture support (legacy) |
### Supported Hardware Events
| Event | Hardware | Action |
|-------|----------|--------|
| **Primary (full press)** | Camera Control, Action Button | Capture photo |
| **Secondary (light press)** | Camera Control | Lock focus/exposure |
| **Volume buttons** | All iPhones | Capture photo |
### Implementation Details
```swift
// CaptureEventInteractionView is added to the camera ZStack
CaptureEventInteractionView(
onCapture: { performCapture() },
onFocusLock: { locked in handleFocusLock(locked) }
)
// The interaction uses AVCaptureEventInteraction (iOS 17.2+)
AVCaptureEventInteraction(
primaryEventHandler: { phase in /* capture on .ended */ },
secondaryEventHandler: { phase in /* focus lock on .began/.ended */ }
)
```
### Device Compatibility
- **iPhone 16+**: Full Camera Control button support (press + light press)
- **iPhone 15 Pro+**: Action button support (when configured for camera)
- **All iPhones**: Volume button shutter via `VolumeButtonObserver`
---
## Premium Feature Implementation ## Premium Feature Implementation
### How Premium Gating Works ### How Premium Gating Works
@ -128,7 +175,6 @@ var isMirrorFlipped: Bool {
| Skin smoothing | Off | Configurable | | Skin smoothing | Off | Configurable |
| Flash sync | Off | Configurable | | Flash sync | Off | Configurable |
| Center stage | Off | Configurable | | Center stage | Off | Configurable |
| Capture modes | Photo | Photo, Video, Boomerang |
--- ---
@ -256,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 ## Future Enhancements
Potential areas for expansion: Potential areas for expansion:
@ -266,6 +367,7 @@ Potential areas for expansion:
- [ ] Apple Watch remote trigger - [ ] Apple Watch remote trigger
- [ ] Export presets (aspect ratios, watermarks) - [ ] Export presets (aspect ratios, watermarks)
- [ ] Social sharing integrations - [ ] Social sharing integrations
- [ ] Camera Control button swipe-to-zoom (if Apple makes API public)
--- ---

View File

@ -15,14 +15,14 @@ Perfect for low-light selfies, content creation, video calls, makeup application
- **Front Flash**: Uses screen brightness for front camera flash effect - **Front Flash**: Uses screen brightness for front camera flash effect
- Real-time camera preview with smooth performance - Real-time camera preview with smooth performance
### Capture Modes ### Capture
- **Photo capture** with high-quality output - **Photo capture** with high-quality output
- **Video recording** (Premium)
- **Boomerang mode** for looping short videos (Premium)
- Self-timer with 3-second (free), 5-second, and 10-second (Premium) options - Self-timer with 3-second (free), 5-second, and 10-second (Premium) options
- Pinch-to-zoom gesture support - Pinch-to-zoom gesture support
- Rule-of-thirds grid overlay (toggleable) - Rule-of-thirds grid overlay (toggleable)
- Post-capture preview with share functionality - Post-capture preview with share functionality
- **Camera Control button** support (iPhone 16+): Full press to capture, light press to lock focus/exposure
- **Hardware shutter**: Volume buttons trigger capture
### Premium Features (Freemium Model) ### Premium Features (Freemium Model)
- **Custom ring light colors** with full color picker - **Custom ring light colors** with full color picker
@ -34,7 +34,6 @@ Perfect for low-light selfies, content creation, video calls, makeup application
- **Skin Smoothing**: Real-time subtle skin smoothing filter - **Skin Smoothing**: Real-time subtle skin smoothing filter
- **Center Stage**: Automatic subject tracking/centering - **Center Stage**: Automatic subject tracking/centering
- **Extended Timers**: 5-second and 10-second self-timer options - **Extended Timers**: 5-second and 10-second self-timer options
- **Video & Boomerang**: Video recording and looping video capture
- Ad-free experience - Ad-free experience
### iCloud Sync ### iCloud Sync
@ -139,15 +138,15 @@ Add `REVENUECAT_API_KEY` as a secret in your Xcode Cloud workflow.
## Privacy ## Privacy
- Camera access required for preview and capture - Camera access required for preview and capture
- Photo Library access required to save photos/videos - Photo Library access required to save photos
- Microphone access required for video recording - Microphone access may be requested by the camera framework (not actively used)
- iCloud access for settings synchronization (optional) - iCloud access for settings synchronization (optional)
- No data collection, no analytics, no tracking - No data collection, no analytics, no tracking
## Monetization ## Monetization
Freemium model with optional "Pro" subscription: Freemium model with optional "Pro" subscription:
- **Free**: Basic ring light, standard colors (Pure White, Warm Cream), photo capture, 3s timer, grid, zoom - **Free**: Basic ring light, standard colors (Pure White, Warm Cream), photo capture, 3s timer, grid, zoom
- **Pro**: Full color palette, custom colors, HDR, high quality, flash sync, true mirror, skin smoothing, center stage, extended timers, video, boomerang - **Pro**: Full color palette, custom colors, HDR, high quality, flash sync, true mirror, skin smoothing, center stage, extended timers
Implemented with RevenueCat for reliable subscription management. Implemented with RevenueCat for reliable subscription management.
@ -165,6 +164,7 @@ SelfieCam/
│ │ │ ├── RingLightOverlay.swift │ │ │ ├── RingLightOverlay.swift
│ │ │ ├── CaptureButton.swift │ │ │ ├── CaptureButton.swift
│ │ │ ├── ExpandableControlsPanel.swift │ │ │ ├── ExpandableControlsPanel.swift
│ │ │ ├── CaptureEventInteraction.swift # Camera Control button support
│ │ │ └── ... │ │ │ └── ...
│ │ ├── GridOverlay.swift # Rule of thirds overlay │ │ ├── GridOverlay.swift # Rule of thirds overlay
│ │ └── PostCapturePreviewView.swift │ │ └── PostCapturePreviewView.swift
@ -186,6 +186,7 @@ SelfieCam/
│ ├── Protocols/ # Shared protocols │ ├── Protocols/ # Shared protocols
│ │ ├── RingLightConfigurable.swift │ │ ├── RingLightConfigurable.swift
│ │ ├── CaptureControlling.swift │ │ ├── CaptureControlling.swift
│ │ ├── CaptureEventHandling.swift # Hardware capture events
│ │ └── PremiumManaging.swift │ │ └── PremiumManaging.swift
│ ├── Premium/ # Subscription management │ ├── Premium/ # Subscription management
│ │ └── PremiumManager.swift │ │ └── PremiumManager.swift

View File

@ -422,15 +422,16 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ; DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos and videos, and enable advanced features like Center Stage auto-framing."; INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record clear audio when capturing videos, ensuring your voiceovers and ambient sounds are captured along with your video content."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos and videos to your device, making them available in the Photos app and other compatible applications."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -459,15 +460,16 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ; DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos and videos, and enable advanced features like Center Stage auto-framing."; INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record clear audio when capturing videos, ensuring your voiceovers and ambient sounds are captured along with your video content."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos and videos to your device, making them available in the Photos app and other compatible applications."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@ -39,7 +39,7 @@ struct ContentView: View {
.ignoresSafeArea() // Only camera ignores safe area to fill screen .ignoresSafeArea() // Only camera ignores safe area to fill screen
} }
// Photo review overlay // Photo review overlay - handles its own safe area
if showPhotoReview, let photo = capturedPhoto { if showPhotoReview, let photo = capturedPhoto {
PhotoReviewView( PhotoReviewView(
photo: photo, photo: photo,
@ -53,24 +53,25 @@ struct ContentView: View {
} }
) )
.transition(.opacity) .transition(.opacity)
.ignoresSafeArea() // Photo review also fills screen
} }
} }
// Settings button overlay - respects safe area naturally // Settings button overlay - only show when NOT in photo review mode
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
Button { if !showPhotoReview {
showSettings = true Button {
} label: { showSettings = true
Image(systemName: "gearshape.fill") } label: {
.font(.title3) Image(systemName: "gearshape.fill")
.foregroundStyle(.white) .font(.title3)
.padding(Design.Spacing.medium) .foregroundStyle(.white)
.background(.ultraThinMaterial, in: Circle()) .padding(Design.Spacing.medium)
.shadow(radius: Design.Shadow.radiusSmall) .background(.ultraThinMaterial, in: Circle())
.shadow(radius: Design.Shadow.radiusSmall)
}
.accessibilityLabel("Settings")
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.small)
} }
.accessibilityLabel("Settings")
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.small)
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview) .animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview)
.onAppear { .onAppear {

View File

@ -0,0 +1,189 @@
//
// CaptureEventInteraction.swift
// SelfieCam
//
// Handles hardware capture events using AVCaptureEventInteraction.
// Supports Camera Control button (iPhone 16+), Action button, and volume buttons.
//
import AVFoundation
import AVKit
import Bedrock
import SwiftUI
import UIKit
/// Manager for AVCaptureEventInteraction that handles Camera Control button events
///
/// This class wraps `AVCaptureEventInteraction` to provide hardware button support:
/// - **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
@Observable @MainActor
final class CaptureEventManager {
private(set) var isActive = false
private(set) var isFocusLocked = false
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)
/// - onFocusLock: Called when focus/exposure lock state changes (light press)
func configure(
onCapture: @escaping () -> Void,
onFocusLock: ((Bool) -> Void)? = nil
) {
self.onCapture = onCapture
self.onFocusLock = onFocusLock
}
/// Creates and returns the AVCaptureEventInteraction to be added to a view
func createInteraction() -> AVCaptureEventInteraction {
Design.debugLog("📸 [CameraControl] Creating AVCaptureEventInteraction...")
let interaction = AVCaptureEventInteraction(
primary: { [weak self] event in
Task { @MainActor [weak self] in
self?.handlePrimaryEvent(event)
}
},
secondary: { [weak self] event in
Task { @MainActor [weak self] in
self?.handleSecondaryEvent(event)
}
}
)
// Must explicitly enable the interaction
interaction.isEnabled = true
// Prepare haptic engines for immediate response
prepareHaptics()
self.eventInteraction = interaction
self.isActive = true
Design.debugLog("📸 [CameraControl] ✅ Hardware capture events ENABLED")
Design.debugLog("📸 [CameraControl] Supported: Full press (capture), Light press (focus lock)")
return interaction
}
/// Removes the interaction and cleans up
func invalidate() {
eventInteraction = nil
isActive = false
isFocusLocked = false
Design.debugLog("📸 [CameraControl] Hardware capture events disabled")
}
// MARK: - Event Handlers
private func handlePrimaryEvent(_ event: AVCaptureEvent) {
Design.debugLog("📸 [CameraControl] PRIMARY - phase: \(event.phase.rawValue)")
switch event.phase {
case .began:
Design.debugLog("📸 [CameraControl] PRIMARY BEGAN - Full press started")
triggerHaptic(.medium)
case .ended:
Design.debugLog("📸 [CameraControl] PRIMARY ENDED - Triggering capture!")
triggerHaptic(.heavy)
onCapture?()
case .cancelled:
Design.debugLog("📸 [CameraControl] PRIMARY CANCELLED")
@unknown default:
Design.debugLog("📸 [CameraControl] PRIMARY UNKNOWN phase: \(event.phase.rawValue)")
}
}
private func handleSecondaryEvent(_ event: AVCaptureEvent) {
Design.debugLog("🔒 [CameraControl] SECONDARY - phase: \(event.phase.rawValue)")
switch event.phase {
case .began:
Design.debugLog("🔒 [CameraControl] SECONDARY BEGAN - Light press! Locking focus...")
isFocusLocked = true
onFocusLock?(true)
triggerHaptic(.medium)
case .ended:
Design.debugLog("🔒 [CameraControl] SECONDARY ENDED - Unlocking focus")
isFocusLocked = false
onFocusLock?(false)
triggerHaptic(.light)
case .cancelled:
Design.debugLog("🔒 [CameraControl] SECONDARY CANCELLED")
isFocusLocked = false
onFocusLock?(false)
@unknown default:
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) {
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()
}
}
}
// MARK: - Device Capability Check
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 {
return true
}
/// Returns a description of supported hardware capture methods
static var supportedHardwareMethods: String {
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 AVFoundation
import AVKit
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import MijickCamera import MijickCamera
@ -52,6 +53,18 @@ struct CustomCameraScreen: MCameraScreen {
createCameraOutputView() createCameraOutputView()
.ignoresSafeArea() .ignoresSafeArea()
.scaleEffect(x: cameraSettings.isMirrorFlipped ? -1 : 1, y: 1) // Apply horizontal mirror flip .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 // Pinch to zoom
.gesture( .gesture(
MagnificationGesture() MagnificationGesture()
@ -412,6 +425,105 @@ 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 {
Design.debugLog("🔒 [FocusLock] ⚠️ No capture device found!")
return
}
do {
try device.lockForConfiguration()
if locked {
// 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")
}
} 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)")
}
}
device.unlockForConfiguration()
} catch {
Design.debugLog("🔒 [FocusLock] ❌ Error: \(error)")
}
}
// MARK: - Camera Gestures // MARK: - Camera Gestures
/// Flips between front and back camera with haptic feedback /// Flips between front and back camera with haptic feedback

View File

@ -1,6 +1,6 @@
// //
// PhotoReviewView.swift // PhotoReviewView.swift
// CameraTester // SelfieCam
// //
// Created by Matt Bruce on 1/2/26. // Created by Matt Bruce on 1/2/26.
// //
@ -17,57 +17,80 @@ struct PhotoReviewView: View {
let onRetake: () -> Void let onRetake: () -> Void
let onSave: () -> Void let onSave: () -> Void
// Layout constants
private let toolbarHeight: CGFloat = 100
private let topButtonSize: CGFloat = 44 // Retake, Close (smaller, out of the way)
private let bottomButtonSize: CGFloat = 64 // Share, Save (larger, main actions)
var body: some View { var body: some View {
ZStack { ZStack {
// Photo display // Black background
Color.black Color.black
.ignoresSafeArea() .ignoresSafeArea()
// Photo - centered in available space
Image(uiImage: photo.image) Image(uiImage: photo.image)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.ignoresSafeArea() .ignoresSafeArea()
}
// Top toolbar with gradient background
.overlay(alignment: .top) {
ZStack(alignment: .bottom) {
// Gradient background for visibility
LinearGradient(
colors: [Color.black.opacity(0.7), Color.black.opacity(0)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: toolbarHeight)
.ignoresSafeArea(edges: .top)
// Top toolbar // Buttons
VStack {
HStack { HStack {
// Retake button // Retake button
Button(action: onRetake) { Button(action: onRetake) {
Image(systemName: "arrow.triangle.2.circlepath") Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(.white) .foregroundStyle(.white)
.frame(width: 44, height: 44) .frame(width: topButtonSize, height: topButtonSize)
.background( .background(.ultraThinMaterial, in: Circle())
Circle()
.fill(Color.black.opacity(0.6))
)
} }
.accessibilityLabel("Retake photo")
Spacer() Spacer()
// Close button // Close button
Button(action: onRetake) { Button(action: onRetake) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(.white) .foregroundStyle(.white)
.frame(width: 44, height: 44) .frame(width: topButtonSize, height: topButtonSize)
.background( .background(.ultraThinMaterial, in: Circle())
Circle()
.fill(Color.black.opacity(0.6))
)
} }
.accessibilityLabel("Close")
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.large) .padding(.bottom, Design.Spacing.medium)
}
}
// Bottom toolbar with gradient background
.overlay(alignment: .bottom) {
ZStack(alignment: .top) {
// Gradient background for visibility
LinearGradient(
colors: [Color.black.opacity(0), Color.black.opacity(0.85)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 160)
.ignoresSafeArea(edges: .bottom)
Spacer()
// Bottom action bar
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
// Save status or error // Save status or error
if let error = saveError { if let error = saveError {
Text(error) Text(error)
.foregroundColor(.red) .foregroundStyle(.red)
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.vertical, Design.Spacing.small) .padding(.vertical, Design.Spacing.small)
@ -81,38 +104,37 @@ struct PhotoReviewView: View {
ProgressView() ProgressView()
.tint(.white) .tint(.white)
Text("Saving...") Text("Saving...")
.foregroundColor(.white) .foregroundStyle(.white)
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
} }
.padding(.vertical, Design.Spacing.small)
} }
// Action buttons // Action buttons - same size, different styling
HStack(spacing: Design.Spacing.xLarge) { HStack(spacing: Design.Spacing.xLarge * 2) {
// Share button // Share button (frosted glass = secondary)
ShareButton(photo: photo.image) ShareButton(photo: photo.image)
.frame(width: bottomButtonSize, height: bottomButtonSize)
.background(.ultraThinMaterial, in: Circle())
// Save button // Save button (solid white = primary)
Button(action: onSave) { Button(action: onSave) {
ZStack { Image(systemName: "checkmark")
Circle() .font(.system(size: 26, weight: .bold))
.fill(Color.white) .foregroundStyle(.black)
.frame(width: 80, height: 80) .frame(width: bottomButtonSize, height: bottomButtonSize)
.background(.white, in: Circle())
Image(systemName: "checkmark") .shadow(color: .black.opacity(0.3), radius: Design.Shadow.radiusMedium)
.font(.system(size: 24, weight: .bold))
.foregroundColor(.black)
}
.shadow(radius: 5)
} }
.disabled(isSaving) .disabled(isSaving)
.accessibilityLabel("Save photo")
.accessibilityHint("Saves the photo to your library")
} }
.padding(.bottom, Design.Spacing.large)
} }
.padding(.horizontal, Design.Spacing.large) .padding(.top, Design.Spacing.xLarge)
.padding(.bottom, Design.Spacing.large)
} }
} }
.accessibilityElement(children: .contain)
.accessibilityLabel("Photo review") .accessibilityLabel("Photo review")
.accessibilityHint("Use the buttons at the bottom to save or share your photo")
} }
} }

View File

@ -15,20 +15,15 @@ struct ShareButton: View {
@State private var isShareSheetPresented = false @State private var isShareSheetPresented = false
var body: some View { var body: some View {
Button(action: { Button {
isShareSheetPresented = true isShareSheetPresented = true
}) { } label: {
ZStack { Image(systemName: "square.and.arrow.up")
Circle() .font(.system(size: 20, weight: .medium))
.fill(Color.black.opacity(0.6)) .foregroundStyle(.white)
.frame(width: 80, height: 80) .frame(maxWidth: .infinity, maxHeight: .infinity)
Image(systemName: "square.and.arrow.up")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
}
.shadow(radius: 5)
} }
.accessibilityLabel("Share photo")
.sheet(isPresented: $isShareSheetPresented) { .sheet(isPresented: $isShareSheetPresented) {
ShareSheet(activityItems: [photo]) ShareSheet(activityItems: [photo])
} }

View File

@ -62,7 +62,7 @@ struct ProPaywallView: View {
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
} }
.background(Color.Surface.overlay) .background(AppSurface.overlay)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@ -102,11 +102,11 @@ private struct ProductPackageButton: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(Design.Spacing.large) .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)) .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large) 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)")) .accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
@ -123,7 +123,7 @@ struct BenefitRow: View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
Image(systemName: image) Image(systemName: image)
.font(.title2) .font(.title2)
.foregroundStyle(Color.Accent.primary) .foregroundStyle(AppAccent.primary)
.frame(width: Design.IconSize.xLarge) .frame(width: Design.IconSize.xLarge)
Text(text) Text(text)

View File

@ -31,7 +31,7 @@ struct ColorPresetButton: View {
.overlay( .overlay(
Circle() Circle()
.strokeBorder( .strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle, isSelected ? AppAccent.primary : AppBorder.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
) )
) )
@ -61,13 +61,13 @@ struct ColorPresetButton: View {
if preset.isPremium { if preset.isPremium {
Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown") Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown")
.font(.system(size: Design.BaseFontSize.xxSmall)) .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) .padding(Design.Spacing.xSmall)
.background( .background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small) 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) .buttonStyle(.plain)

View File

@ -36,7 +36,7 @@ struct CustomColorPickerButton: View {
.overlay( .overlay(
Circle() Circle()
.strokeBorder( .strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle, isSelected ? AppAccent.primary : AppBorder.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
) )
) )
@ -53,12 +53,12 @@ struct CustomColorPickerButton: View {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.xxSmall)) .font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
} }
.padding(Design.Spacing.xSmall) .padding(Design.Spacing.xSmall)
.background( .background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small) 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")) .accessibilityLabel(String(localized: "Custom color"))
.accessibilityAddTraits(isSelected ? .isSelected : []) .accessibilityAddTraits(isSelected ? .isSelected : [])
@ -78,7 +78,7 @@ struct CustomColorPickerButton: View {
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall) .frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.overlay( .overlay(
Circle() Circle()
.strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin) .strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
) )
// Lock overlay // Lock overlay
@ -99,7 +99,7 @@ struct CustomColorPickerButton: View {
Image(systemName: "crown") Image(systemName: "crown")
.font(.system(size: Design.BaseFontSize.xxSmall)) .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) .padding(Design.Spacing.xSmall)
} }

View File

@ -32,7 +32,7 @@ struct LicensesView: View {
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
} }
.background(Color.Surface.overlay) .background(AppSurface.overlay)
.navigationTitle(String(localized: "Open Source Licenses")) .navigationTitle(String(localized: "Open Source Licenses"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
@ -50,7 +50,7 @@ struct LicensesView: View {
HStack { HStack {
Label(license, systemImage: "doc.text") Label(license, systemImage: "doc.text")
.font(.system(size: Design.BaseFontSize.xSmall)) .font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(Color.Accent.primary) .foregroundStyle(AppAccent.primary)
Spacer() Spacer()
@ -58,13 +58,13 @@ struct LicensesView: View {
Link(destination: linkURL) { Link(destination: linkURL) {
Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square") Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square")
.font(.system(size: Design.BaseFontSize.xSmall)) .font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(Color.Accent.primary) .foregroundStyle(AppAccent.primary)
} }
} }
} }
.padding(.top, Design.Spacing.xSmall) .padding(.top, Design.Spacing.xSmall)
} }
.padding(Design.Spacing.medium) .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 // MARK: - Ring Light Section
SettingsSectionHeader(title: "Ring Light", systemImage: "light.max") SettingsSectionHeader(title: "Ring Light", systemImage: "light.max", accentColor: AppAccent.primary)
// Ring Light Enabled SettingsCard {
SettingsToggle( // Ring Light Enabled
title: String(localized: "Enable Ring Light"), SettingsToggle(
subtitle: String(localized: "Show colored light ring around camera preview"), title: String(localized: "Enable Ring Light"),
isOn: $viewModel.isRingLightEnabled subtitle: String(localized: "Show colored light ring around camera preview"),
) isOn: $viewModel.isRingLightEnabled,
.accessibilityHint(String(localized: "Enables or disables the ring light overlay")) accentColor: AppAccent.primary
)
.accessibilityHint(String(localized: "Enables or disables the ring light overlay"))
// Ring Size Slider // Ring Size Slider
ringSizeSlider ringSizeSlider
// Color Preset // Color Preset
colorPresetSection colorPresetSection
// Ring Light Brightness // Ring Light Brightness
ringLightBrightnessSlider ringLightBrightnessSlider
}
// MARK: - Camera Controls Section // MARK: - Camera Controls Section
SettingsSectionHeader(title: "Camera Controls", systemImage: "camera") SettingsSectionHeader(title: "Camera Controls", systemImage: "camera", accentColor: AppAccent.primary)
// Camera Position SettingsCard {
cameraPositionPicker // Camera Position
cameraPositionPicker
// Flash Mode // Flash Mode
flashModePicker flashModePicker
// Flash Sync (premium) // Flash Sync (premium)
premiumToggle( premiumToggle(
title: String(localized: "Flash Sync"), title: String(localized: "Flash Sync"),
subtitle: String(localized: "Use ring light color for screen flash"), subtitle: String(localized: "Use ring light color for screen flash"),
isOn: $viewModel.isFlashSyncedWithRingLight, isOn: $viewModel.isFlashSyncedWithRingLight,
accessibilityHint: String(localized: "Syncs flash color with ring light color") accessibilityHint: String(localized: "Syncs flash color with ring light color")
) )
// HDR Mode // HDR Mode
hdrModePicker hdrModePicker
// Center Stage (premium feature) // Center Stage (premium feature)
centerStageToggle centerStageToggle
// Photo Quality // Photo Quality
photoQualityPicker photoQualityPicker
}
// MARK: - Display Section // MARK: - Display Section
SettingsSectionHeader(title: "Display", systemImage: "eye") SettingsSectionHeader(title: "Display", systemImage: "eye", accentColor: AppAccent.primary)
// True Mirror (premium) SettingsCard {
premiumToggle( // True Mirror (premium)
title: String(localized: "True Mirror"), premiumToggle(
subtitle: String(localized: "Shows horizontally flipped preview like a real mirror"), title: String(localized: "True Mirror"),
isOn: $viewModel.isMirrorFlipped, subtitle: String(localized: "Shows horizontally flipped preview like a real mirror"),
accessibilityHint: String(localized: "Flips the camera preview horizontally") isOn: $viewModel.isMirrorFlipped,
) accessibilityHint: String(localized: "Flips the camera preview horizontally")
)
SettingsToggle( SettingsToggle(
title: String(localized: "Grid Overlay"), title: String(localized: "Grid Overlay"),
subtitle: String(localized: "Shows rule of thirds grid for composition"), subtitle: String(localized: "Shows rule of thirds grid for composition"),
isOn: $viewModel.isGridVisible isOn: $viewModel.isGridVisible,
) accentColor: AppAccent.primary
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot")) )
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
// Skin Smoothing (premium) // Skin Smoothing (premium)
premiumToggle( premiumToggle(
title: String(localized: "Skin Smoothing"), title: String(localized: "Skin Smoothing"),
subtitle: String(localized: "Applies subtle real-time skin smoothing"), subtitle: String(localized: "Applies subtle real-time skin smoothing"),
isOn: $viewModel.isSkinSmoothingEnabled, isOn: $viewModel.isSkinSmoothingEnabled,
accessibilityHint: String(localized: "Applies light skin smoothing to the camera preview") accessibilityHint: String(localized: "Applies light skin smoothing to the camera preview")
) )
}
// MARK: - Capture Section // MARK: - Capture Section
SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle") SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle", accentColor: AppAccent.primary)
// Timer Selection SettingsCard {
timerPicker // Timer Selection
timerPicker
SettingsToggle( SettingsToggle(
title: String(localized: "Auto-Save"), title: String(localized: "Auto-Save"),
subtitle: String(localized: "Automatically save captures to Photo Library"), subtitle: String(localized: "Automatically save captures to Photo Library"),
isOn: $viewModel.isAutoSaveEnabled isOn: $viewModel.isAutoSaveEnabled,
) accentColor: AppAccent.primary
.accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture")) )
.accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture"))
}
// MARK: - Pro Section // MARK: - Pro Section
SettingsSectionHeader(title: "Pro", systemImage: "crown") SettingsSectionHeader(title: "Pro", systemImage: "crown", accentColor: AppStatus.warning)
proSection proSection
// MARK: - Sync Section // 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 // MARK: - About Section
SettingsSectionHeader(title: "About", systemImage: "info.circle") SettingsSectionHeader(title: "About", systemImage: "info.circle", accentColor: AppAccent.primary)
acknowledgmentsSection acknowledgmentsSection
// MARK: - Debug Section // MARK: - Debug Section
#if DEBUG #if DEBUG
SettingsSectionHeader(title: "Debug", systemImage: "ant.fill") SettingsSectionHeader(title: "Debug", systemImage: "ant.fill", accentColor: AppStatus.error)
brandingDebugSection SettingsCard {
brandingDebugSection
}
#endif #endif
Spacer(minLength: Design.Spacing.xxxLarge) Spacer(minLength: Design.Spacing.xxxLarge)
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
} }
.background(Color.Surface.overlay) .background(AppSurface.primary)
.navigationTitle(String(localized: "Settings")) .navigationTitle(String(localized: "Settings"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -153,7 +168,7 @@ struct SettingsView: View {
Button(String(localized: "Done")) { Button(String(localized: "Done")) {
dismiss() dismiss()
} }
.foregroundStyle(Color.Accent.primary) .foregroundStyle(AppAccent.primary)
} }
} }
} }
@ -186,7 +201,7 @@ struct SettingsView: View {
in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize, in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize,
step: 5 step: 5
) )
.tint(Color.Accent.primary) .tint(AppAccent.primary)
// Large ring icon // Large ring icon
Image(systemName: "circle") Image(systemName: "circle")
@ -286,7 +301,7 @@ struct SettingsView: View {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
} }
Text(String(localized: "High Dynamic Range for better lighting in photos")) Text(String(localized: "High Dynamic Range for better lighting in photos"))
@ -319,7 +334,7 @@ struct SettingsView: View {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
} }
Text(isPremiumUnlocked Text(isPremiumUnlocked
@ -354,7 +369,7 @@ struct SettingsView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background( .background(
Capsule() 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) .buttonStyle(.plain)
@ -415,7 +430,7 @@ struct SettingsView: View {
in: 0.1...1.0, in: 0.1...1.0,
step: 0.05 step: 0.05
) )
.tint(Color.Accent.primary) .tint(AppAccent.primary)
Image(systemName: "sun.max.fill") Image(systemName: "sun.max.fill")
.font(.system(size: Design.BaseFontSize.large)) .font(.system(size: Design.BaseFontSize.large))
@ -443,7 +458,7 @@ struct SettingsView: View {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
} }
Text(String(localized: "Automatically keeps you centered in the frame")) Text(String(localized: "Automatically keeps you centered in the frame"))
@ -451,7 +466,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
} }
.tint(Color.Accent.primary) .tint(AppAccent.primary)
.padding(.vertical, Design.Spacing.xSmall) .padding(.vertical, Design.Spacing.xSmall)
.disabled(!isPremiumUnlocked) .disabled(!isPremiumUnlocked)
.accessibilityLabel(String(localized: "Enable Center Stage")) .accessibilityLabel(String(localized: "Enable Center Stage"))
@ -477,7 +492,7 @@ struct SettingsView: View {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
} }
Text(isPremiumUnlocked Text(isPremiumUnlocked
@ -512,7 +527,7 @@ struct SettingsView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background( .background(
Capsule() 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) .buttonStyle(.plain)
@ -536,7 +551,7 @@ struct SettingsView: View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.title2) .font(.title2)
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(String(localized: "Upgrade to Pro")) Text(String(localized: "Upgrade to Pro"))
@ -557,8 +572,8 @@ struct SettingsView: View {
.padding(Design.Spacing.medium) .padding(Design.Spacing.medium)
.background( .background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.Accent.primary.opacity(Design.Opacity.subtle)) .fill(AppAccent.primary.opacity(Design.Opacity.subtle))
.strokeBorder(Color.Accent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) .strokeBorder(AppAccent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@ -584,7 +599,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
} }
.tint(Color.Accent.primary) .tint(AppAccent.primary)
.padding(.vertical, Design.Spacing.xSmall) .padding(.vertical, Design.Spacing.xSmall)
.disabled(!viewModel.iCloudAvailable) .disabled(!viewModel.iCloudAvailable)
.accessibilityHint(String(localized: "Syncs settings across all your devices via iCloud")) .accessibilityHint(String(localized: "Syncs settings across all your devices via iCloud"))
@ -607,7 +622,7 @@ struct SettingsView: View {
} label: { } label: {
Text(String(localized: "Sync Now")) Text(String(localized: "Sync Now"))
.font(.system(size: Design.BaseFontSize.caption, weight: .medium)) .font(.system(size: Design.BaseFontSize.caption, weight: .medium))
.foregroundStyle(Color.Accent.primary) .foregroundStyle(AppAccent.primary)
} }
} }
.padding(.top, Design.Spacing.xSmall) .padding(.top, Design.Spacing.xSmall)
@ -626,9 +641,9 @@ struct SettingsView: View {
private var syncStatusColor: Color { private var syncStatusColor: Color {
if !viewModel.hasCompletedInitialSync { if !viewModel.hasCompletedInitialSync {
return Color.Status.warning return AppStatus.warning
} }
return Color.Status.success return AppStatus.success
} }
private var syncStatusText: String { private var syncStatusText: String {
@ -672,7 +687,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
.padding(Design.Spacing.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) .buttonStyle(.plain)
} }
@ -703,7 +718,7 @@ struct SettingsView: View {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
} }
Text(subtitle) Text(subtitle)
@ -711,7 +726,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
} }
.tint(Color.Accent.primary) .tint(AppAccent.primary)
.padding(.vertical, Design.Spacing.xSmall) .padding(.vertical, Design.Spacing.xSmall)
.disabled(!isPremiumUnlocked) .disabled(!isPremiumUnlocked)
.accessibilityHint(accessibilityHint) .accessibilityHint(accessibilityHint)
@ -744,7 +759,7 @@ struct SettingsView: View {
Image(systemName: "crown.fill") Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(Color.Status.warning) .foregroundStyle(AppStatus.warning)
Spacer() Spacer()
} }
@ -764,7 +779,7 @@ struct SettingsView: View {
subtitle: "Unlock all premium features for testing", subtitle: "Unlock all premium features for testing",
isOn: $viewModel.isDebugPremiumEnabled isOn: $viewModel.isDebugPremiumEnabled
) )
.tint(Color.Status.warning) .tint(AppStatus.warning)
// Icon Generator // Icon Generator
NavigationLink { NavigationLink {
IconGeneratorView(config: .selfieCam, appName: "SelfieCam") IconGeneratorView(config: .selfieCam, appName: "SelfieCam")
@ -787,7 +802,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
.padding(Design.Spacing.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) .buttonStyle(.plain)
@ -817,7 +832,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
.padding(Design.Spacing.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) .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 { #Preview {
SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false)) SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false))
.preferredColorScheme(.dark) .preferredColorScheme(.dark)

View File

@ -507,6 +507,7 @@
}, },
"Boomerang" : { "Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.", "comment" : "Display name for the \"Boomerang\" capture mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -769,6 +770,10 @@
} }
} }
}, },
"Close" : {
"comment" : "A button label that closes the view.",
"isCommentAutoGenerated" : true
},
"Close preview" : { "Close preview" : {
"comment" : "A button label that closes the preview screen.", "comment" : "A button label that closes the preview screen.",
"extractionState" : "stale", "extractionState" : "stale",
@ -1691,6 +1696,7 @@
} }
}, },
"Photo" : { "Photo" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
"stringUnit" : { "stringUnit" : {
@ -1957,6 +1963,10 @@
} }
} }
}, },
"Retake photo" : {
"comment" : "A button that, when tapped, allows the user to retake a photo.",
"isCommentAutoGenerated" : true
},
"Ring Light" : { "Ring Light" : {
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -2148,6 +2158,10 @@
} }
} }
}, },
"Save photo" : {
"comment" : "A button that saves the currently displayed photo to the user's library.",
"isCommentAutoGenerated" : true
},
"Saved to Photos" : { "Saved to Photos" : {
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.", "comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"extractionState" : "stale", "extractionState" : "stale",
@ -2173,6 +2187,10 @@
} }
} }
}, },
"Saves the photo to your library" : {
"comment" : "An accessibility hint for the save button in the photo review view.",
"isCommentAutoGenerated" : true
},
"Saving..." : { "Saving..." : {
"comment" : "A text that appears while a photo is being saved.", "comment" : "A text that appears while a photo is being saved.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -2390,6 +2408,10 @@
} }
} }
}, },
"Share photo" : {
"comment" : "An accessibility label for the share button.",
"isCommentAutoGenerated" : true
},
"Show colored light ring around camera preview" : { "Show colored light ring around camera preview" : {
"comment" : "Subtitle for the \"Enable Ring Light\" toggle in the Settings view.", "comment" : "Subtitle for the \"Enable Ring Light\" toggle in the Settings view.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -3016,6 +3038,7 @@
}, },
"Use the buttons at the bottom to save or share your photo" : { "Use the buttons at the bottom to save or share your photo" : {
"comment" : "An accessibility hint for the photo review view, instructing the user on how to interact with the view.", "comment" : "An accessibility hint for the photo review view, instructing the user on how to interact with the view.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -3065,6 +3088,7 @@
}, },
"Video" : { "Video" : {
"comment" : "Display name for the \"Video\" capture mode.", "comment" : "Display name for the \"Video\" capture mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {

View File

@ -0,0 +1,45 @@
//
// CaptureEventHandling.swift
// SelfieCam
//
// Protocol for handling hardware capture events (Camera Control button, Action button, volume buttons).
//
import Foundation
/// Protocol defining hardware capture event handling capabilities
///
/// Conforming types can respond to hardware button events for camera capture,
/// including the Camera Control button on iPhone 16+, Action button on iPhone 15 Pro+,
/// and volume buttons.
///
/// - Note: This protocol is designed to work with `AVCaptureEventInteraction` (iOS 17.2+)
protocol CaptureEventHandling: AnyObject {
/// Called when a primary capture event occurs (full press on Camera Control, volume button press)
/// - Parameter phase: The phase of the event (.began, .ended, .cancelled)
func handlePrimaryCaptureEvent(phase: CaptureEventPhase)
/// Called when a secondary capture event occurs (light press on Camera Control for focus/exposure)
/// - Parameter phase: The phase of the event (.began, .ended, .cancelled)
func handleSecondaryCaptureEvent(phase: CaptureEventPhase)
}
/// Represents the phase of a hardware capture event
enum CaptureEventPhase: Sendable {
/// Event began (button pressed down or light press detected)
case began
/// Event ended (button released)
case ended
/// Event was cancelled
case cancelled
}
// MARK: - Default Implementations
extension CaptureEventHandling {
/// Default implementation for secondary events (focus/exposure lock)
/// Override in conforming types if focus/exposure lock is needed
func handleSecondaryCaptureEvent(phase: CaptureEventPhase) {
// Default: no-op, override if focus/exposure lock support is needed
}
}

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