Compare commits
4 Commits
5499b10b2a
...
255f3c2d68
| Author | SHA1 | Date | |
|---|---|---|---|
| 255f3c2d68 | |||
| 66736d39f9 | |||
| f7c554d018 | |||
| fe7ab135af |
@ -522,6 +522,7 @@ SelfieCam uses the following architectural patterns:
|
||||
| `RingLightConfigurable` | Ring light settings (size, color, opacity) | `SettingsViewModel` |
|
||||
| `CaptureControlling` | Capture actions (timer, flash, shutter) | `SettingsViewModel` |
|
||||
| `PremiumManaging` | Subscription state and purchases | `PremiumManager` |
|
||||
| `CaptureEventHandling` | Hardware button capture events (Camera Control, Action button) | `CaptureEventManager` |
|
||||
|
||||
|
||||
## Premium Features
|
||||
@ -553,7 +554,6 @@ SelfieCam uses the following architectural patterns:
|
||||
- Skin smoothing
|
||||
- Center Stage
|
||||
- Extended timers (5s, 10s)
|
||||
- Video and Boomerang capture modes
|
||||
|
||||
|
||||
## Settings & iCloud Sync
|
||||
@ -621,7 +621,6 @@ var flashMode: CameraFlashMode // .off, .on, .auto
|
||||
- Front/back camera switching
|
||||
- Pinch-to-zoom
|
||||
- Photo capture with quality settings
|
||||
- Video recording (premium)
|
||||
- HDR mode (premium)
|
||||
|
||||
|
||||
|
||||
@ -75,7 +75,8 @@ Features/
|
||||
- Post-capture preview with share functionality
|
||||
- Auto-save option to Photo Library
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### How Premium Gating Works
|
||||
@ -128,7 +175,6 @@ var isMirrorFlipped: Bool {
|
||||
| Skin smoothing | Off | Configurable |
|
||||
| Flash sync | 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
|
||||
|
||||
Potential areas for expansion:
|
||||
@ -266,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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
15
README.md
15
README.md
@ -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
|
||||
- Real-time camera preview with smooth performance
|
||||
|
||||
### Capture Modes
|
||||
### Capture
|
||||
- **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
|
||||
- Pinch-to-zoom gesture support
|
||||
- Rule-of-thirds grid overlay (toggleable)
|
||||
- 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)
|
||||
- **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
|
||||
- **Center Stage**: Automatic subject tracking/centering
|
||||
- **Extended Timers**: 5-second and 10-second self-timer options
|
||||
- **Video & Boomerang**: Video recording and looping video capture
|
||||
- Ad-free experience
|
||||
|
||||
### iCloud Sync
|
||||
@ -139,15 +138,15 @@ Add `REVENUECAT_API_KEY` as a secret in your Xcode Cloud workflow.
|
||||
|
||||
## Privacy
|
||||
- Camera access required for preview and capture
|
||||
- Photo Library access required to save photos/videos
|
||||
- Microphone access required for video recording
|
||||
- Photo Library access required to save photos
|
||||
- Microphone access may be requested by the camera framework (not actively used)
|
||||
- iCloud access for settings synchronization (optional)
|
||||
- No data collection, no analytics, no tracking
|
||||
|
||||
## Monetization
|
||||
Freemium model with optional "Pro" subscription:
|
||||
- **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.
|
||||
|
||||
@ -165,6 +164,7 @@ SelfieCam/
|
||||
│ │ │ ├── RingLightOverlay.swift
|
||||
│ │ │ ├── CaptureButton.swift
|
||||
│ │ │ ├── ExpandableControlsPanel.swift
|
||||
│ │ │ ├── CaptureEventInteraction.swift # Camera Control button support
|
||||
│ │ │ └── ...
|
||||
│ │ ├── GridOverlay.swift # Rule of thirds overlay
|
||||
│ │ └── PostCapturePreviewView.swift
|
||||
@ -186,6 +186,7 @@ SelfieCam/
|
||||
│ ├── Protocols/ # Shared protocols
|
||||
│ │ ├── RingLightConfigurable.swift
|
||||
│ │ ├── CaptureControlling.swift
|
||||
│ │ ├── CaptureEventHandling.swift # Hardware capture events
|
||||
│ │ └── PremiumManaging.swift
|
||||
│ ├── Premium/ # Subscription management
|
||||
│ │ └── PremiumManager.swift
|
||||
|
||||
@ -422,15 +422,16 @@
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
ENABLE_PREVIEWS = 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_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_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_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 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 to your device, making them available in the Photos app and other compatible applications.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -459,15 +460,16 @@
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
ENABLE_PREVIEWS = 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_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_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_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 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 to your device, making them available in the Photos app and other compatible applications.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@ -39,7 +39,7 @@ struct ContentView: View {
|
||||
.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 {
|
||||
PhotoReviewView(
|
||||
photo: photo,
|
||||
@ -53,24 +53,25 @@ struct ContentView: View {
|
||||
}
|
||||
)
|
||||
.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) {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.shadow(radius: Design.Shadow.radiusSmall)
|
||||
if !showPhotoReview {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white)
|
||||
.padding(Design.Spacing.medium)
|
||||
.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)
|
||||
.onAppear {
|
||||
|
||||
189
SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift
Normal file
189
SelfieCam/Features/Camera/Views/CaptureEventInteraction.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
/// Flips between front and back camera with haptic feedback
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
//
|
||||
// PhotoReviewView.swift
|
||||
// CameraTester
|
||||
// SelfieCam
|
||||
//
|
||||
// Created by Matt Bruce on 1/2/26.
|
||||
//
|
||||
@ -16,58 +16,81 @@ struct PhotoReviewView: View {
|
||||
let saveError: String?
|
||||
let onRetake: () -> 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 {
|
||||
ZStack {
|
||||
// Photo display
|
||||
// Black background
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Photo - centered in available space
|
||||
Image(uiImage: photo.image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Top toolbar
|
||||
VStack {
|
||||
}
|
||||
// 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)
|
||||
|
||||
// Buttons
|
||||
HStack {
|
||||
// Retake button
|
||||
Button(action: onRetake) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: topButtonSize, height: topButtonSize)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Retake photo")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Close button
|
||||
Button(action: onRetake) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: topButtonSize, height: topButtonSize)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.large)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom action bar
|
||||
.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)
|
||||
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
// Save status or error
|
||||
if let error = saveError {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.foregroundStyle(.red)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
@ -81,38 +104,37 @@ struct PhotoReviewView: View {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
Text("Saving...")
|
||||
.foregroundColor(.white)
|
||||
.foregroundStyle(.white)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: Design.Spacing.xLarge) {
|
||||
// Share button
|
||||
// Action buttons - same size, different styling
|
||||
HStack(spacing: Design.Spacing.xLarge * 2) {
|
||||
// Share button (frosted glass = secondary)
|
||||
ShareButton(photo: photo.image)
|
||||
.frame(width: bottomButtonSize, height: bottomButtonSize)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
|
||||
// Save button
|
||||
// Save button (solid white = primary)
|
||||
Button(action: onSave) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.shadow(radius: 5)
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.frame(width: bottomButtonSize, height: bottomButtonSize)
|
||||
.background(.white, in: Circle())
|
||||
.shadow(color: .black.opacity(0.3), radius: Design.Shadow.radiusMedium)
|
||||
}
|
||||
.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")
|
||||
.accessibilityHint("Use the buttons at the bottom to save or share your photo")
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,20 +15,15 @@ struct ShareButton: View {
|
||||
@State private var isShareSheetPresented = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
Button {
|
||||
isShareSheetPresented = true
|
||||
}) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.6))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 24, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.shadow(radius: 5)
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.accessibilityLabel("Share photo")
|
||||
.sheet(isPresented: $isShareSheetPresented) {
|
||||
ShareSheet(activityItems: [photo])
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,124 +28,139 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: - Ring Light Section
|
||||
|
||||
SettingsSectionHeader(title: "Ring Light", systemImage: "light.max")
|
||||
SettingsSectionHeader(title: "Ring Light", systemImage: "light.max", accentColor: AppAccent.primary)
|
||||
|
||||
SettingsCard {
|
||||
// Ring Light Enabled
|
||||
SettingsToggle(
|
||||
title: String(localized: "Enable Ring Light"),
|
||||
subtitle: String(localized: "Show colored light ring around camera preview"),
|
||||
isOn: $viewModel.isRingLightEnabled,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
.accessibilityHint(String(localized: "Enables or disables the ring light overlay"))
|
||||
|
||||
// Ring Light Enabled
|
||||
SettingsToggle(
|
||||
title: String(localized: "Enable Ring Light"),
|
||||
subtitle: String(localized: "Show colored light ring around camera preview"),
|
||||
isOn: $viewModel.isRingLightEnabled
|
||||
)
|
||||
.accessibilityHint(String(localized: "Enables or disables the ring light overlay"))
|
||||
// Ring Size Slider
|
||||
ringSizeSlider
|
||||
|
||||
// Ring Size Slider
|
||||
ringSizeSlider
|
||||
// Color Preset
|
||||
colorPresetSection
|
||||
|
||||
// Color Preset
|
||||
colorPresetSection
|
||||
|
||||
// Ring Light Brightness
|
||||
ringLightBrightnessSlider
|
||||
// Ring Light Brightness
|
||||
ringLightBrightnessSlider
|
||||
}
|
||||
|
||||
// MARK: - Camera Controls Section
|
||||
|
||||
SettingsSectionHeader(title: "Camera Controls", systemImage: "camera")
|
||||
SettingsSectionHeader(title: "Camera Controls", systemImage: "camera", accentColor: AppAccent.primary)
|
||||
|
||||
SettingsCard {
|
||||
// Camera Position
|
||||
cameraPositionPicker
|
||||
|
||||
// Camera Position
|
||||
cameraPositionPicker
|
||||
// Flash Mode
|
||||
flashModePicker
|
||||
|
||||
// Flash Mode
|
||||
flashModePicker
|
||||
// Flash Sync (premium)
|
||||
premiumToggle(
|
||||
title: String(localized: "Flash Sync"),
|
||||
subtitle: String(localized: "Use ring light color for screen flash"),
|
||||
isOn: $viewModel.isFlashSyncedWithRingLight,
|
||||
accessibilityHint: String(localized: "Syncs flash color with ring light color")
|
||||
)
|
||||
|
||||
// Flash Sync (premium)
|
||||
premiumToggle(
|
||||
title: String(localized: "Flash Sync"),
|
||||
subtitle: String(localized: "Use ring light color for screen flash"),
|
||||
isOn: $viewModel.isFlashSyncedWithRingLight,
|
||||
accessibilityHint: String(localized: "Syncs flash color with ring light color")
|
||||
)
|
||||
// HDR Mode
|
||||
hdrModePicker
|
||||
|
||||
// HDR Mode
|
||||
hdrModePicker
|
||||
// Center Stage (premium feature)
|
||||
centerStageToggle
|
||||
|
||||
// Center Stage (premium feature)
|
||||
centerStageToggle
|
||||
|
||||
// Photo Quality
|
||||
photoQualityPicker
|
||||
// Photo Quality
|
||||
photoQualityPicker
|
||||
}
|
||||
|
||||
// MARK: - Display Section
|
||||
|
||||
SettingsSectionHeader(title: "Display", systemImage: "eye")
|
||||
SettingsSectionHeader(title: "Display", systemImage: "eye", accentColor: AppAccent.primary)
|
||||
|
||||
SettingsCard {
|
||||
// True Mirror (premium)
|
||||
premiumToggle(
|
||||
title: String(localized: "True Mirror"),
|
||||
subtitle: String(localized: "Shows horizontally flipped preview like a real mirror"),
|
||||
isOn: $viewModel.isMirrorFlipped,
|
||||
accessibilityHint: String(localized: "Flips the camera preview horizontally")
|
||||
)
|
||||
|
||||
// True Mirror (premium)
|
||||
premiumToggle(
|
||||
title: String(localized: "True Mirror"),
|
||||
subtitle: String(localized: "Shows horizontally flipped preview like a real mirror"),
|
||||
isOn: $viewModel.isMirrorFlipped,
|
||||
accessibilityHint: String(localized: "Flips the camera preview horizontally")
|
||||
)
|
||||
SettingsToggle(
|
||||
title: String(localized: "Grid Overlay"),
|
||||
subtitle: String(localized: "Shows rule of thirds grid for composition"),
|
||||
isOn: $viewModel.isGridVisible,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
|
||||
|
||||
SettingsToggle(
|
||||
title: String(localized: "Grid Overlay"),
|
||||
subtitle: String(localized: "Shows rule of thirds grid for composition"),
|
||||
isOn: $viewModel.isGridVisible
|
||||
)
|
||||
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
|
||||
|
||||
// Skin Smoothing (premium)
|
||||
premiumToggle(
|
||||
title: String(localized: "Skin Smoothing"),
|
||||
subtitle: String(localized: "Applies subtle real-time skin smoothing"),
|
||||
isOn: $viewModel.isSkinSmoothingEnabled,
|
||||
accessibilityHint: String(localized: "Applies light skin smoothing to the camera preview")
|
||||
)
|
||||
// Skin Smoothing (premium)
|
||||
premiumToggle(
|
||||
title: String(localized: "Skin Smoothing"),
|
||||
subtitle: String(localized: "Applies subtle real-time skin smoothing"),
|
||||
isOn: $viewModel.isSkinSmoothingEnabled,
|
||||
accessibilityHint: String(localized: "Applies light skin smoothing to the camera preview")
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Capture Section
|
||||
|
||||
SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle")
|
||||
SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle", accentColor: AppAccent.primary)
|
||||
|
||||
SettingsCard {
|
||||
// Timer Selection
|
||||
timerPicker
|
||||
|
||||
// Timer Selection
|
||||
timerPicker
|
||||
|
||||
SettingsToggle(
|
||||
title: String(localized: "Auto-Save"),
|
||||
subtitle: String(localized: "Automatically save captures to Photo Library"),
|
||||
isOn: $viewModel.isAutoSaveEnabled
|
||||
)
|
||||
.accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture"))
|
||||
SettingsToggle(
|
||||
title: String(localized: "Auto-Save"),
|
||||
subtitle: String(localized: "Automatically save captures to Photo Library"),
|
||||
isOn: $viewModel.isAutoSaveEnabled,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
.accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture"))
|
||||
}
|
||||
|
||||
// MARK: - Pro Section
|
||||
|
||||
SettingsSectionHeader(title: "Pro", systemImage: "crown")
|
||||
SettingsSectionHeader(title: "Pro", systemImage: "crown", accentColor: AppStatus.warning)
|
||||
|
||||
proSection
|
||||
|
||||
// MARK: - Sync Section
|
||||
|
||||
SettingsSectionHeader(title: String(localized: "iCloud Sync"), systemImage: "icloud")
|
||||
|
||||
iCloudSyncSection
|
||||
SettingsSectionHeader(title: String(localized: "iCloud Sync"), systemImage: "icloud", accentColor: AppAccent.primary)
|
||||
|
||||
SettingsCard {
|
||||
iCloudSyncSection
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
|
||||
SettingsSectionHeader(title: "About", systemImage: "info.circle")
|
||||
SettingsSectionHeader(title: "About", systemImage: "info.circle", accentColor: AppAccent.primary)
|
||||
|
||||
acknowledgmentsSection
|
||||
|
||||
// MARK: - Debug Section
|
||||
|
||||
#if DEBUG
|
||||
SettingsSectionHeader(title: "Debug", systemImage: "ant.fill")
|
||||
SettingsSectionHeader(title: "Debug", systemImage: "ant.fill", accentColor: AppStatus.error)
|
||||
|
||||
brandingDebugSection
|
||||
SettingsCard {
|
||||
brandingDebugSection
|
||||
}
|
||||
#endif
|
||||
|
||||
Spacer(minLength: Design.Spacing.xxxLarge)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
}
|
||||
.background(Color.Surface.overlay)
|
||||
.background(AppSurface.primary)
|
||||
.navigationTitle(String(localized: "Settings"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@ -153,7 +168,7 @@ struct SettingsView: View {
|
||||
Button(String(localized: "Done")) {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(Color.Accent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -186,7 +201,7 @@ struct SettingsView: View {
|
||||
in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize,
|
||||
step: 5
|
||||
)
|
||||
.tint(Color.Accent.primary)
|
||||
.tint(AppAccent.primary)
|
||||
|
||||
// Large ring icon
|
||||
Image(systemName: "circle")
|
||||
@ -286,7 +301,7 @@ struct SettingsView: View {
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
}
|
||||
|
||||
Text(String(localized: "High Dynamic Range for better lighting in photos"))
|
||||
@ -319,7 +334,7 @@ struct SettingsView: View {
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
}
|
||||
|
||||
Text(isPremiumUnlocked
|
||||
@ -354,7 +369,7 @@ struct SettingsView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(viewModel.photoQuality == quality ? Color.Accent.primary : Color.white.opacity(Design.Opacity.subtle))
|
||||
.fill(viewModel.photoQuality == quality ? AppAccent.primary : Color.white.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@ -415,7 +430,7 @@ struct SettingsView: View {
|
||||
in: 0.1...1.0,
|
||||
step: 0.05
|
||||
)
|
||||
.tint(Color.Accent.primary)
|
||||
.tint(AppAccent.primary)
|
||||
|
||||
Image(systemName: "sun.max.fill")
|
||||
.font(.system(size: Design.BaseFontSize.large))
|
||||
@ -443,7 +458,7 @@ struct SettingsView: View {
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
}
|
||||
|
||||
Text(String(localized: "Automatically keeps you centered in the frame"))
|
||||
@ -451,7 +466,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
}
|
||||
.tint(Color.Accent.primary)
|
||||
.tint(AppAccent.primary)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
.disabled(!isPremiumUnlocked)
|
||||
.accessibilityLabel(String(localized: "Enable Center Stage"))
|
||||
@ -477,7 +492,7 @@ struct SettingsView: View {
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
}
|
||||
|
||||
Text(isPremiumUnlocked
|
||||
@ -512,7 +527,7 @@ struct SettingsView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(viewModel.selectedTimer == option ? Color.Accent.primary : Color.white.opacity(Design.Opacity.subtle))
|
||||
.fill(viewModel.selectedTimer == option ? AppAccent.primary : Color.white.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@ -536,7 +551,7 @@ struct SettingsView: View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text(String(localized: "Upgrade to Pro"))
|
||||
@ -557,8 +572,8 @@ struct SettingsView: View {
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.Accent.primary.opacity(Design.Opacity.subtle))
|
||||
.strokeBorder(Color.Accent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||
.fill(AppAccent.primary.opacity(Design.Opacity.subtle))
|
||||
.strokeBorder(AppAccent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@ -584,7 +599,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
}
|
||||
.tint(Color.Accent.primary)
|
||||
.tint(AppAccent.primary)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
.disabled(!viewModel.iCloudAvailable)
|
||||
.accessibilityHint(String(localized: "Syncs settings across all your devices via iCloud"))
|
||||
@ -607,7 +622,7 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
Text(String(localized: "Sync Now"))
|
||||
.font(.system(size: Design.BaseFontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Color.Accent.primary)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
.padding(.top, Design.Spacing.xSmall)
|
||||
@ -626,9 +641,9 @@ struct SettingsView: View {
|
||||
|
||||
private var syncStatusColor: Color {
|
||||
if !viewModel.hasCompletedInitialSync {
|
||||
return Color.Status.warning
|
||||
return AppStatus.warning
|
||||
}
|
||||
return Color.Status.success
|
||||
return AppStatus.success
|
||||
}
|
||||
|
||||
private var syncStatusText: String {
|
||||
@ -672,7 +687,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||
.background(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -703,7 +718,7 @@ struct SettingsView: View {
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
}
|
||||
|
||||
Text(subtitle)
|
||||
@ -711,7 +726,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
}
|
||||
.tint(Color.Accent.primary)
|
||||
.tint(AppAccent.primary)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
.disabled(!isPremiumUnlocked)
|
||||
.accessibilityHint(accessibilityHint)
|
||||
@ -744,7 +759,7 @@ struct SettingsView: View {
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(Color.Status.warning)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@ -764,7 +779,7 @@ struct SettingsView: View {
|
||||
subtitle: "Unlock all premium features for testing",
|
||||
isOn: $viewModel.isDebugPremiumEnabled
|
||||
)
|
||||
.tint(Color.Status.warning)
|
||||
.tint(AppStatus.warning)
|
||||
// Icon Generator
|
||||
NavigationLink {
|
||||
IconGeneratorView(config: .selfieCam, appName: "SelfieCam")
|
||||
@ -787,7 +802,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||
.background(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@ -817,7 +832,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||
.background(AppSurface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -827,6 +842,29 @@ struct SettingsView: View {
|
||||
|
||||
|
||||
|
||||
// MARK: - Settings Card Container
|
||||
|
||||
/// A card container that provides visual grouping for settings sections.
|
||||
/// Uses the app's branded surface colors for separation from the background.
|
||||
private struct SettingsCard<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)
|
||||
|
||||
@ -507,6 +507,7 @@
|
||||
},
|
||||
"Boomerang" : {
|
||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
@ -769,6 +770,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Close" : {
|
||||
"comment" : "A button label that closes the view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Close preview" : {
|
||||
"comment" : "A button label that closes the preview screen.",
|
||||
"extractionState" : "stale",
|
||||
@ -1691,6 +1696,7 @@
|
||||
}
|
||||
},
|
||||
"Photo" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
@ -1957,6 +1963,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Retake photo" : {
|
||||
"comment" : "A button that, when tapped, allows the user to retake a photo.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ring Light" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
||||
"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..." : {
|
||||
"comment" : "A text that appears while a photo is being saved.",
|
||||
"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" : {
|
||||
"comment" : "Subtitle for the \"Enable Ring Light\" toggle in the Settings view.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -3016,6 +3038,7 @@
|
||||
},
|
||||
"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.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
@ -3065,6 +3088,7 @@
|
||||
},
|
||||
"Video" : {
|
||||
"comment" : "Display name for the \"Video\" capture mode.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
|
||||
45
SelfieCam/Shared/Protocols/CaptureEventHandling.swift
Normal file
45
SelfieCam/Shared/Protocols/CaptureEventHandling.swift
Normal 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
|
||||
}
|
||||
}
|
||||
144
SelfieCam/Shared/SelfieCamTheme.swift
Normal file
144
SelfieCam/Shared/SelfieCamTheme.swift
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user