Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-04 11:26:00 -06:00
parent fd25942f59
commit 915088f180
22 changed files with 2331 additions and 84 deletions

View File

@ -1,22 +1,22 @@
{
"originHash" : "f0492d428a7eee59a60d8a8f71928cd6379f7e9632aa4a32cbd1f1cea00a553b",
"originHash" : "6833d23e21d5837a21eb6a505b1a3ec76c3c83999c658fd917d3d4e7c53f82a3",
"pins" : [
{
"identity" : "bedrock",
"kind" : "remoteSourceControl",
"location" : "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git",
"location" : "http://192.168.1.128:3000/mbrucedogs/Bedrock",
"state" : {
"branch" : "develop",
"revision" : "9f4046bfd2c23e76c30dfefe0ed164405b1b0ee8"
"branch" : "master",
"revision" : "8e788ef2121024b25ac6150d857140af6bcd64c2"
}
},
{
"identity" : "camera",
"identity" : "mijickcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Mijick/Camera",
"location" : "http://192.168.1.128:3000/mbrucedogs/MijickCamera",
"state" : {
"revision" : "0f02348fcc8fbbc9224c7fbf444f182dc25d0b40",
"version" : "3.0.3"
"branch" : "develop",
"revision" : "55940f0d52a69ab1959ec80118956ddbeca085f9"
}
},
{

View File

@ -2,51 +2,104 @@ import SwiftUI
import MijickCamera
import Bedrock
/// Main camera view with ring light effect using MijickCamera
// MARK: - Camera Settings Observable
/// Shared observable class for camera settings to prevent MCamera recreation on changes
@Observable @MainActor
final class CameraSettingsState {
var photoQuality: PhotoQuality
var isRingLightEnabled: Bool
var ringLightColor: Color
var ringLightSize: CGFloat
var ringLightOpacity: Double
var flashMode: CameraFlashMode
var isFlashSyncedWithRingLight: Bool
var hdrMode: CameraHDRMode
var isGridVisible: Bool
var cameraPosition: CameraPosition
init(settings: SettingsViewModel) {
self.photoQuality = settings.photoQuality
self.isRingLightEnabled = settings.isRingLightEnabled
self.ringLightColor = settings.lightColor
self.ringLightSize = settings.ringSize
self.ringLightOpacity = settings.ringLightOpacity
self.flashMode = settings.flashMode
self.isFlashSyncedWithRingLight = settings.isFlashSyncedWithRingLight
self.hdrMode = settings.hdrMode
self.isGridVisible = settings.isGridVisible
self.cameraPosition = settings.cameraPosition
}
func update(from settings: SettingsViewModel) {
self.photoQuality = settings.photoQuality
self.isRingLightEnabled = settings.isRingLightEnabled
self.ringLightColor = settings.lightColor
self.ringLightSize = settings.ringSize
self.ringLightOpacity = settings.ringLightOpacity
self.flashMode = settings.flashMode
self.isFlashSyncedWithRingLight = settings.isFlashSyncedWithRingLight
self.hdrMode = settings.hdrMode
self.isGridVisible = settings.isGridVisible
self.cameraPosition = settings.cameraPosition
}
}
struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var premiumManager = PremiumManager()
@State private var showSettings = false
@State private var showPaywall = false
// Post-capture state
@State private var capturedImage: UIImage?
@State private var capturedVideoURL: URL?
@State private var showPostCapture = false
/// Ring size clamped to reasonable max
private var effectiveRingSize: CGFloat {
let maxRing = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * 0.2
return min(settings.ringSize, maxRing)
}
@State private var capturedPhoto: CapturedPhoto?
@State private var showPhotoReview = false
@State private var isSavingPhoto = false
@State private var saveError: String?
/// Camera settings in a shared observable to prevent MCamera recreation
@State private var cameraSettings: CameraSettingsState?
/// Unique key to force MCamera recreation after photo capture
/// Incrementing this value creates a new camera session with fresh AVCapturePhotoOutput
@State private var cameraSessionKey = UUID()
var body: some View {
ZStack {
// Ring light background
settings.lightColor
.ignoresSafeArea()
// MijickCamera with default UI
MCamera()
.setCameraPosition(.front) // Default to front camera for selfies
.onImageCaptured { image, _ in
capturedImage = image
showPostCapture = true
}
.onVideoCaptured { url, _ in
capturedVideoURL = url
showPostCapture = true
}
.startSession()
.padding(.horizontal, effectiveRingSize)
.padding(.top, effectiveRingSize)
.padding(.bottom, effectiveRingSize)
// Settings button overlay (top right corner of camera area)
// Camera view - only recreates when sessionKey changes due to Equatable
if !showPhotoReview {
CameraContainerView(
cameraSettings: cameraSettings ?? CameraSettingsState(settings: settings),
sessionKey: cameraSessionKey,
onImageCaptured: { image in
capturedPhoto = CapturedPhoto(image: image, timestamp: Date())
showPhotoReview = true
isSavingPhoto = false
saveError = nil
print("Photo captured successfully")
}
)
}
// Photo review overlay
if showPhotoReview, let photo = capturedPhoto {
PhotoReviewView(
photo: photo,
isSaving: isSavingPhoto,
saveError: saveError,
onRetake: {
resetCameraForNextCapture()
},
onSave: {
savePhotoToLibrary(photo.image)
}
)
.transition(.opacity)
}
// Settings button overlay
VStack {
HStack {
Spacer()
Button {
showSettings = true
} label: {
@ -59,45 +112,117 @@ struct ContentView: View {
}
.accessibilityLabel("Settings")
}
.padding(.horizontal, effectiveRingSize + Design.Spacing.medium)
.padding(.top, effectiveRingSize + Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.medium)
Spacer()
}
}
.ignoresSafeArea()
.animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview)
.onAppear {
cameraSettings = CameraSettingsState(settings: settings)
}
.onChange(of: settings.photoQuality) { _, _ in updateCameraSettings() }
.onChange(of: settings.isRingLightEnabled) { _, newValue in
updateCameraSettings()
if settings.isFlashSyncedWithRingLight {
settings.flashMode = newValue ? .on : .off
}
}
.onChange(of: settings.lightColor) { _, _ in updateCameraSettings() }
.onChange(of: settings.ringSize) { _, _ in updateCameraSettings() }
.onChange(of: settings.ringLightOpacity) { _, _ in updateCameraSettings() }
.onChange(of: settings.flashMode) { _, _ in updateCameraSettings() }
.onChange(of: settings.isFlashSyncedWithRingLight) { _, newValue in
updateCameraSettings()
if newValue {
settings.flashMode = settings.isRingLightEnabled ? .on : .off
}
}
.onChange(of: settings.hdrMode) { _, _ in updateCameraSettings() }
.onChange(of: settings.isGridVisible) { _, _ in updateCameraSettings() }
.onChange(of: settings.cameraPosition) { _, _ in updateCameraSettings() }
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: settings, showPaywall: $showPaywall)
}
.sheet(isPresented: $showPaywall) {
ProPaywallView()
}
.fullScreenCover(isPresented: $showPostCapture) {
PostCapturePreviewView(
capturedImage: capturedImage,
capturedVideoURL: capturedVideoURL,
isAutoSaveEnabled: settings.isAutoSaveEnabled,
onRetake: {
capturedImage = nil
capturedVideoURL = nil
showPostCapture = false
},
onSave: {
saveCapture()
showPostCapture = false
}
private func updateCameraSettings() {
cameraSettings?.update(from: settings)
}
/// Resets state and regenerates camera session key to create a fresh camera instance
private func resetCameraForNextCapture() {
capturedPhoto = nil
showPhotoReview = false
isSavingPhoto = false
saveError = nil
// Generate new key to force MCamera recreation with fresh AVCapturePhotoOutput
cameraSessionKey = UUID()
}
private func savePhotoToLibrary(_ image: UIImage) {
isSavingPhoto = true
saveError = nil
let quality = cameraSettings?.photoQuality ?? .high
Task {
let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality)
await MainActor.run {
self.isSavingPhoto = false
switch result {
case .success:
print("Photo saved successfully")
// Auto-dismiss after successful save and reset camera for next capture
Task {
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
self.resetCameraForNextCapture()
}
}
case .failure(let error):
print("Failed to save photo: \(error)")
self.saveError = error.localizedDescription
}
)
}
}
}
// MARK: - Save Capture
private func saveCapture() {
if let image = capturedImage {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
capturedImage = nil
capturedVideoURL = nil
}
// MARK: - Camera Container View
/// Wrapper view for MCamera - only recreates when sessionKey changes
struct CameraContainerView: View, Equatable {
let cameraSettings: CameraSettingsState
let sessionKey: UUID
let onImageCaptured: (UIImage) -> Void
// Only compare sessionKey for equality - ignore settings and callback changes
static func == (lhs: CameraContainerView, rhs: CameraContainerView) -> Bool {
lhs.sessionKey == rhs.sessionKey
}
var body: some View {
let _ = print("CameraContainerView body evaluated - sessionKey: \(sessionKey)")
MCamera()
.setCameraScreen { cameraManager, namespace, closeAction in
CustomCameraScreen(
cameraManager: cameraManager,
namespace: namespace,
closeMCameraAction: closeAction,
cameraSettings: cameraSettings,
onPhotoCaptured: onImageCaptured
)
}
.setCapturedMediaScreen(nil)
.setCameraPosition(cameraSettings.cameraPosition)
.startSession()
}
}

View File

@ -51,9 +51,9 @@ struct PostCapturePreviewView: View {
}
.sheet(isPresented: $showShareSheet) {
if let image = capturedImage {
ShareSheet(items: [image])
ShareSheet(activityItems: [image])
} else if let url = capturedVideoURL {
ShareSheet(items: [url])
ShareSheet(activityItems: [url])
}
}
}
@ -209,17 +209,7 @@ private struct ToolbarButton: View {
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
PostCapturePreviewView(

View File

@ -0,0 +1,38 @@
//
// CaptureButton.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import SwiftUI
import Bedrock
// MARK: - Capture Button
struct CaptureButton: View {
let action: () -> Void
// Layout constants
private let outerSize: CGFloat = 80
private let innerSize: CGFloat = 68
var body: some View {
Button(action: action) {
ZStack {
// Outer ring
Circle()
.stroke(Color.white, lineWidth: 4)
.frame(width: outerSize, height: outerSize)
// Inner fill
Circle()
.fill(Color.white)
.frame(width: innerSize, height: innerSize)
}
.shadow(radius: Design.Shadow.radiusMedium)
}
.accessibilityLabel("Take photo")
.accessibilityHint("Double tap to capture a photo")
}
}

View File

@ -0,0 +1,711 @@
//
// CustomCameraScreen.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import AVFoundation
import SwiftUI
import Bedrock
import MijickCamera
// MARK: - Custom Camera Screen
struct CustomCameraScreen: MCameraScreen {
@ObservedObject var cameraManager: CameraManager
let namespace: Namespace.ID
let closeMCameraAction: () -> ()
/// Shared camera settings state - using Observable class prevents MCamera recreation
var cameraSettings: CameraSettingsState
/// Callback when photo is captured - bypasses MijickCamera's callback system
var onPhotoCaptured: ((UIImage) -> Void)?
// Convenience accessors for settings
private var photoQuality: PhotoQuality {
get { cameraSettings.photoQuality }
nonmutating set { cameraSettings.photoQuality = newValue }
}
private var isRingLightEnabled: Bool {
get { cameraSettings.isRingLightEnabled }
nonmutating set { cameraSettings.isRingLightEnabled = newValue }
}
private var ringLightColor: Color {
get { cameraSettings.ringLightColor }
nonmutating set { cameraSettings.ringLightColor = newValue }
}
private var ringLightSize: CGFloat {
get { cameraSettings.ringLightSize }
nonmutating set { cameraSettings.ringLightSize = newValue }
}
private var ringLightOpacity: Double {
get { cameraSettings.ringLightOpacity }
nonmutating set { cameraSettings.ringLightOpacity = newValue }
}
private var flashMode: CameraFlashMode {
get { cameraSettings.flashMode }
nonmutating set { cameraSettings.flashMode = newValue }
}
private var isFlashSyncedWithRingLight: Bool {
get { cameraSettings.isFlashSyncedWithRingLight }
nonmutating set { cameraSettings.isFlashSyncedWithRingLight = newValue }
}
// Center Stage state
@State private var isCenterStageEnabled: Bool = AVCaptureDevice.isCenterStageEnabled
// Controls panel expansion state
@State private var isControlsExpanded: Bool = false
// Ring light settings overlay state
@State private var showRingLightColorPicker: Bool = false
@State private var showRingLightSizeSlider: Bool = false
@State private var showRingLightOpacitySlider: Bool = false
// Screen flash state for front camera
@State private var isShowingScreenFlash: Bool = false
@State private var originalBrightness: CGFloat = UIScreen.main.brightness
// Pinch to zoom gesture state
@GestureState private var magnification: CGFloat = 1.0
@State private var lastMagnification: CGFloat = 1.0
var body: some View {
ZStack {
// Camera preview with pinch gesture - Metal layer doesn't respect SwiftUI clipping
createCameraOutputView()
.ignoresSafeArea()
.gesture(
MagnificationGesture()
.updating($magnification) { currentState, gestureState, transaction in
gestureState = currentState
}
.onEnded { value in
let newZoom = lastMagnification * value
lastMagnification = newZoom
// Clamp to reasonable range
let clampedZoom = min(max(newZoom, 1.0), 5.0)
do {
try setZoomFactor(clampedZoom)
} catch {
print("Failed to set zoom factor: \(error)")
}
}
)
// Ring light overlay - covers corners and creates rounded inner edge
// When ring light is off, still show black corners to maintain rounded appearance
RingLightOverlay(
color: isRingLightEnabled ? ringLightColor : .black,
width: isRingLightEnabled ? ringLightSize : Design.CornerRadius.large,
opacity: isRingLightEnabled ? ringLightOpacity : 1.0,
cornerRadius: Design.CornerRadius.large
)
.allowsHitTesting(false) // Allow touches to pass through to camera view
// UI overlay - responsive to orientation
GeometryReader { geometry in
let isLandscape = geometry.size.width > geometry.size.height
if isLandscape {
// Landscape layout: full-width centered controls, capture button on left with zoom above
ZStack {
// Centered controls across entire screen
VStack(spacing: 0) {
// Top controls area - expandable panel (centered)
ExpandableControlsPanel(
isExpanded: $isControlsExpanded,
hasActiveSettings: hasActiveSettings,
activeSettingsIcons: activeSettingsIcons,
flashMode: flashMode,
flashIcon: flashIcon,
onFlashTap: toggleFlash,
isFlashSyncedWithRingLight: isFlashSyncedWithRingLight,
onFlashSyncTap: toggleFlashSync,
hdrMode: cameraSettings.hdrMode,
hdrIcon: hdrIcon,
onHDRTap: toggleHDR,
isGridVisible: isGridVisible,
gridIcon: gridIcon,
onGridTap: toggleGrid,
photoQuality: photoQuality,
onQualityTap: cycleQuality,
isCenterStageAvailable: isCenterStageAvailable,
isCenterStageEnabled: isCenterStageEnabled,
onCenterStageTap: toggleCenterStage,
isFrontCamera: cameraPosition == .front,
onFlipCameraTap: flipCamera,
isRingLightEnabled: isRingLightEnabled,
onRingLightTap: toggleRingLight,
ringLightColor: ringLightColor,
onRingLightColorTap: toggleRingLightColorPicker,
ringLightSize: ringLightSize,
onRingLightSizeTap: toggleRingLightSizeSlider,
ringLightOpacity: ringLightOpacity,
onRingLightOpacityTap: toggleRingLightOpacitySlider
)
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.medium)
Spacer()
}
// Left side overlay - Capture Button only
VStack {
Spacer()
CaptureButton(action: { performCapture() })
Spacer()
}
.padding(.leading, Design.Spacing.large)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
} else {
// Portrait layout: controls on top, capture button at bottom
VStack(spacing: 0) {
// Top controls area - expandable panel
ExpandableControlsPanel(
isExpanded: $isControlsExpanded,
hasActiveSettings: hasActiveSettings,
activeSettingsIcons: activeSettingsIcons,
flashMode: flashMode,
flashIcon: flashIcon,
onFlashTap: toggleFlash,
isFlashSyncedWithRingLight: isFlashSyncedWithRingLight,
onFlashSyncTap: toggleFlashSync,
hdrMode: cameraSettings.hdrMode,
hdrIcon: hdrIcon,
onHDRTap: toggleHDR,
isGridVisible: isGridVisible,
gridIcon: gridIcon,
onGridTap: toggleGrid,
photoQuality: photoQuality,
onQualityTap: cycleQuality,
isCenterStageAvailable: isCenterStageAvailable,
isCenterStageEnabled: isCenterStageEnabled,
onCenterStageTap: toggleCenterStage,
isFrontCamera: cameraPosition == .front,
onFlipCameraTap: flipCamera,
isRingLightEnabled: isRingLightEnabled,
onRingLightTap: toggleRingLight,
ringLightColor: ringLightColor,
onRingLightColorTap: toggleRingLightColorPicker,
ringLightSize: ringLightSize,
onRingLightSizeTap: toggleRingLightSizeSlider,
ringLightOpacity: ringLightOpacity,
onRingLightOpacityTap: toggleRingLightOpacitySlider
)
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.medium)
Spacer()
// Bottom controls
VStack(spacing: Design.Spacing.large) {
// Zoom indicator (shows "Center Stage" when active)
ZoomControlView(
zoomFactor: zoomFactor,
isCenterStageActive: isCenterStageEnabled
)
// Capture Button
CaptureButton(action: { performCapture() })
.padding(.bottom, Design.Spacing.large)
}
.padding(.horizontal, Design.Spacing.large)
}
}
}
// Ring light color picker overlay
if showRingLightColorPicker {
ColorPickerOverlay(
selectedColor: Binding(
get: { cameraSettings.ringLightColor },
set: { cameraSettings.ringLightColor = $0 }
),
isPresented: $showRingLightColorPicker
)
.transition(.opacity)
}
// Ring light size slider overlay
if showRingLightSizeSlider {
SizeSliderOverlay(
selectedSize: Binding(
get: { cameraSettings.ringLightSize },
set: { cameraSettings.ringLightSize = $0 }
),
isPresented: $showRingLightSizeSlider
)
.transition(.opacity)
}
// Ring light opacity slider overlay
if showRingLightOpacitySlider {
OpacitySliderOverlay(
selectedOpacity: Binding(
get: { cameraSettings.ringLightOpacity },
set: { cameraSettings.ringLightOpacity = $0 }
),
isPresented: $showRingLightOpacitySlider
)
.transition(.opacity)
}
// Screen flash overlay for front camera
if isShowingScreenFlash {
screenFlashColor
.ignoresSafeArea()
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.05), value: isShowingScreenFlash)
.gesture(
// Only add tap gesture when there are overlays to dismiss
(isControlsExpanded || showRingLightColorPicker || showRingLightSizeSlider || showRingLightOpacitySlider) ?
TapGesture().onEnded {
// Collapse panel when tapping outside
if isControlsExpanded {
isControlsExpanded = false
}
// Hide overlays when tapping outside
if showRingLightColorPicker {
showRingLightColorPicker = false
}
if showRingLightSizeSlider {
showRingLightSizeSlider = false
}
if showRingLightOpacitySlider {
showRingLightOpacitySlider = false
}
} : nil
)
.onAppear {
// Set flash mode from saved settings
setFlashMode(cameraSettings.flashMode.toMijickFlashMode)
// Tell MijickCamera whether to disable iOS flash (only matters if sync is on)
updateFlashSyncState()
// Initialize zoom gesture state
lastMagnification = zoomFactor
}
.onChange(of: isFlashSyncedWithRingLight) { _, _ in
// Only update when sync setting changes, not on every color change
updateFlashSyncState()
}
.onChange(of: cameraManager.capturedMedia) { _, newMedia in
// Directly observe capture completion - bypasses MijickCamera's callback issues
if let media = newMedia, let image = media.getImage() {
print("CustomCameraScreen detected captured media!")
onPhotoCaptured?(image)
// Clear the captured media so next capture works
cameraManager.setCapturedMedia(nil)
}
}
}
// MARK: - Active Settings Detection
/// Returns true if any setting is in a non-default state
private var hasActiveSettings: Bool {
flashMode != .off || cameraSettings.hdrMode != .off || isGridVisible || isCenterStageEnabled || isRingLightEnabled
}
/// Returns icons for currently active settings (for collapsed pill display)
private var activeSettingsIcons: [String] {
var icons: [String] = []
if flashMode != .off {
icons.append(flashIcon)
}
if cameraSettings.hdrMode != .off {
icons.append(hdrIcon)
}
if isGridVisible {
icons.append(gridIcon)
}
if isRingLightEnabled {
icons.append("circle.fill")
}
if isCenterStageEnabled {
icons.append("person.crop.rectangle.fill")
}
return icons
}
// MARK: - Control Icons
private var flashIcon: String {
flashMode.icon
}
private var hdrIcon: String {
switch cameraSettings.hdrMode {
case .off: return "circle.lefthalf.filled"
case .auto: return "circle.lefthalf.filled"
case .on: return "circle.fill"
}
}
private var gridIcon: String {
isGridVisible ? "grid" : "grid"
}
private var isCenterStageAvailable: Bool {
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
return false
}
return device.activeFormat.isCenterStageSupported
}
// MARK: - Actions
private func toggleFlash() {
let nextMode: CameraFlashMode
switch flashMode {
case .off: nextMode = .auto
case .auto: nextMode = .on
case .on: nextMode = .off
}
flashMode = nextMode
// Update MijickCamera's flash mode so it knows to use iOS Retina Flash
setFlashMode(nextMode.toMijickFlashMode)
}
private func toggleFlashSync() {
isFlashSyncedWithRingLight.toggle()
}
private func toggleHDR() {
Task {
do {
let nextMode: CameraHDRMode
switch cameraSettings.hdrMode {
case .off: nextMode = .auto
case .auto: nextMode = .on
case .on: nextMode = .off
}
try setHDRMode(nextMode.toMijickHDRMode)
cameraSettings.hdrMode = nextMode
} catch {
print("Failed to set HDR mode: \(error)")
}
}
}
private func toggleGrid() {
setGridVisibility(!isGridVisible)
}
private func flipCamera() {
Task {
do {
let newPosition: CameraPosition = (cameraPosition == .front) ? .back : .front
try await setCameraPosition(newPosition)
} catch {
print("Failed to flip camera: \(error)")
}
}
}
private func toggleCenterStage() {
// Get the current camera device using AVFoundation
let deviceTypes: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera
]
guard let device = AVCaptureDevice.default(deviceTypes[0], for: .video, position: cameraPosition == .front ? .front : .back) else {
print("No camera device available for Center Stage toggle")
return
}
do {
// Configure Center Stage globally (these are static properties)
try device.lockForConfiguration()
// Set control mode to app-controlled
if device.activeFormat.isCenterStageSupported {
AVCaptureDevice.centerStageControlMode = .app
AVCaptureDevice.isCenterStageEnabled = !isCenterStageEnabled
}
device.unlockForConfiguration()
// Update our state
isCenterStageEnabled.toggle()
print("Center Stage toggled to: \(isCenterStageEnabled)")
} catch {
print("Failed to toggle Center Stage: \(error)")
}
}
private func cycleQuality() {
let allCases = PhotoQuality.allCases
let currentIndex = allCases.firstIndex(of: photoQuality) ?? 0
let nextIndex = (currentIndex + 1) % allCases.count
photoQuality = allCases[nextIndex]
}
private func toggleRingLight() {
isRingLightEnabled.toggle()
}
private func updateFlashSyncState() {
// Tell MijickCamera whether we're handling flash ourselves (sync enabled)
// or if iOS should handle it (sync disabled)
// We use .white as a placeholder - the actual color comes from ringLightColor in SwiftUI
if isFlashSyncedWithRingLight {
setScreenFlashColor(.white) // Non-nil = we handle flash, disable iOS flash
} else {
setScreenFlashColor(nil) // nil = let iOS handle Retina Flash
}
}
/// The color to use for screen flash overlay
private var screenFlashColor: Color {
isFlashSyncedWithRingLight ? ringLightColor : .white
}
/// Whether to use custom screen flash (front camera + flash on + sync enabled)
private var shouldUseCustomScreenFlash: Bool {
cameraPosition == .front && flashMode != .off && isFlashSyncedWithRingLight
}
/// Performs capture with screen flash if needed
private func performCapture() {
print("performCapture called - shouldUseCustomScreenFlash: \(shouldUseCustomScreenFlash)")
if shouldUseCustomScreenFlash {
// Save original brightness and boost to max
originalBrightness = UIScreen.main.brightness
UIScreen.main.brightness = 1.0
// Show flash overlay
isShowingScreenFlash = true
// Wait for camera to adjust to bright screen, then capture
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(150))
print("Calling captureOutput() with custom flash")
captureOutput()
// Keep flash visible briefly after capture
try? await Task.sleep(for: .milliseconds(100))
isShowingScreenFlash = false
UIScreen.main.brightness = originalBrightness
}
} else {
// Normal capture (iOS handles Retina Flash for front camera if needed)
print("Calling captureOutput() without custom flash")
captureOutput()
}
}
private func toggleRingLightColorPicker() {
showRingLightColorPicker = true
showRingLightSizeSlider = false // Hide other overlay
isControlsExpanded = false // Collapse controls panel
}
private func toggleRingLightSizeSlider() {
showRingLightSizeSlider = true
showRingLightColorPicker = false // Hide other overlay
showRingLightOpacitySlider = false // Hide other overlay
isControlsExpanded = false // Collapse controls panel
}
private func toggleRingLightOpacitySlider() {
showRingLightOpacitySlider = true
showRingLightColorPicker = false // Hide other overlay
showRingLightSizeSlider = false // Hide other overlay
isControlsExpanded = false // Collapse controls panel
}
}
// MARK: - Color Picker Overlay
struct ColorPickerOverlay: View {
@Binding var selectedColor: Color
@Binding var isPresented: Bool
private let colors: [Color] = [
.white, .red, .orange, .yellow, .green, .blue, .purple, .pink,
.gray, .black, Color(red: 1.0, green: 0.5, blue: 0.0), // Coral
Color(red: 0.5, green: 1.0, blue: 0.5), // Mint
Color(red: 0.5, green: 0.5, blue: 1.0), // Periwinkle
Color(red: 1.0, green: 0.5, blue: 1.0), // Magenta
]
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Color picker content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Color")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Color grid
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: Design.Spacing.small) {
ForEach(colors, id: \.self) { color in
Circle()
.fill(color)
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.white.opacity(selectedColor == color ? 1.0 : 0.3), lineWidth: selectedColor == color ? 3 : 1)
)
.onTapGesture {
selectedColor = color
isPresented = false
}
}
}
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}
// MARK: - Opacity Slider Overlay
struct OpacitySliderOverlay: View {
@Binding var selectedOpacity: Double
@Binding var isPresented: Bool
private let minOpacity: Double = 0.1
private let maxOpacity: Double = 1.0
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Opacity slider content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Brightness")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Current opacity display as percentage
Text("\(Int(selectedOpacity * 100))%")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.white)
.frame(width: 80)
// Slider
Slider(value: $selectedOpacity, in: minOpacity...maxOpacity, step: 0.05)
.tint(Color.white)
.padding(.horizontal, Design.Spacing.medium)
// Opacity range labels
HStack {
Text("10%")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
Spacer()
Text("100%")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, Design.Spacing.medium)
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}
// MARK: - Size Slider Overlay
struct SizeSliderOverlay: View {
@Binding var selectedSize: CGFloat
@Binding var isPresented: Bool
private let minSize: CGFloat = 50
private let maxSize: CGFloat = 100
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(Design.Opacity.medium)
.ignoresSafeArea()
// Size slider content
VStack(spacing: Design.Spacing.medium) {
// Header
Text("Ring Light Size")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
// Current size display
Text("\(Int(selectedSize))")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Color.white)
.frame(width: 60)
// Slider
Slider(value: $selectedSize, in: minSize...maxSize, step: 5)
.tint(Color.white)
.padding(.horizontal, Design.Spacing.medium)
// Size range labels
HStack {
Text("\(Int(minSize))")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
Spacer()
Text("\(Int(maxSize))")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, Design.Spacing.medium)
// Done button
Button("Done") {
isPresented = false
}
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color.white)
.padding(.vertical, Design.Spacing.small)
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.black.opacity(Design.Opacity.strong))
)
.padding(.horizontal, Design.Spacing.large)
}
}
}

View File

@ -0,0 +1,254 @@
//
// ExpandableControlsPanel.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import SwiftUI
import Bedrock
import MijickCamera
// MARK: - Expandable Controls Panel
struct ExpandableControlsPanel: View {
@Binding var isExpanded: Bool
// Collapsed state info
let hasActiveSettings: Bool
let activeSettingsIcons: [String]
// Control properties
let flashMode: CameraFlashMode
let flashIcon: String
let onFlashTap: () -> Void
let isFlashSyncedWithRingLight: Bool
let onFlashSyncTap: () -> Void
let hdrMode: CameraHDRMode
let hdrIcon: String
let onHDRTap: () -> Void
let isGridVisible: Bool
let gridIcon: String
let onGridTap: () -> Void
let photoQuality: PhotoQuality
let onQualityTap: () -> Void
let isCenterStageAvailable: Bool
let isCenterStageEnabled: Bool
let onCenterStageTap: () -> Void
let isFrontCamera: Bool
let onFlipCameraTap: () -> Void
let isRingLightEnabled: Bool
let onRingLightTap: () -> Void
let ringLightColor: Color
let onRingLightColorTap: () -> Void
let ringLightSize: CGFloat
let onRingLightSizeTap: () -> Void
let ringLightOpacity: Double
let onRingLightOpacityTap: () -> Void
// Layout constants
private let collapsedHeight: CGFloat = 36
private let iconSize: CGFloat = 16
private let maxVisibleIcons: Int = 3
// Grid layout
private let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack(spacing: 0) {
// Header row (always visible) - acts as the collapsed pill
Button(action: { isExpanded.toggle() }) {
HStack(spacing: Design.Spacing.small) {
// Chevron that rotates
Image(systemName: "chevron.down")
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(Color.white)
.rotationEffect(.degrees(isExpanded ? 180 : 0))
// Show active setting icons when collapsed
if !isExpanded && hasActiveSettings {
HStack(spacing: Design.Spacing.xSmall) {
ForEach(activeSettingsIcons.prefix(maxVisibleIcons), id: \.self) { icon in
Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(Color.yellow)
}
}
}
if isExpanded {
Spacer()
}
}
.frame(height: collapsedHeight)
.frame(maxWidth: isExpanded ? .infinity : nil)
.padding(.horizontal, Design.Spacing.medium)
}
.accessibilityLabel("Camera controls")
.accessibilityHint(isExpanded ? "Tap to collapse settings" : "Tap to expand camera settings")
// Expandable content
if isExpanded {
ScrollView {
VStack(spacing: Design.Spacing.large) {
// Camera Controls Section
VStack(spacing: Design.Spacing.medium) {
// Section header
Text("Camera Controls")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
// Controls grid
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
// Flash
ExpandedControlItem(
icon: flashIcon,
label: flashLabel,
isActive: flashMode != .off,
action: onFlashTap
)
// Flash Sync
ExpandedControlItem(
icon: isFlashSyncedWithRingLight ? "link" : "link.slash",
label: "SYNC",
isActive: isFlashSyncedWithRingLight,
action: onFlashSyncTap
)
// HDR
ExpandedControlItem(
icon: hdrIcon,
label: hdrLabel,
isActive: hdrMode != .off,
action: onHDRTap
)
// Grid
ExpandedControlItem(
icon: gridIcon,
label: "GRID",
isActive: isGridVisible,
action: onGridTap
)
// Quality
ExpandedControlItem(
icon: photoQuality.icon,
label: photoQuality.rawValue.uppercased(),
isActive: false,
action: onQualityTap
)
// Center Stage (if available)
if isCenterStageAvailable {
ExpandedControlItem(
icon: isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle",
label: "STAGE",
isActive: isCenterStageEnabled,
action: onCenterStageTap
)
}
// Flip Camera
ExpandedControlItem(
icon: "arrow.triangle.2.circlepath.camera",
label: isFrontCamera ? "FRONT" : "BACK",
isActive: false,
action: onFlipCameraTap
)
}
}
// Ring Light Section
VStack(spacing: Design.Spacing.medium) {
// Section header
Text("Ring Light")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
// Ring light controls grid
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
// Ring Light Enable/Disable
ExpandedControlItem(
icon: isRingLightEnabled ? "circle.fill" : "circle",
label: "ENABLE",
isActive: isRingLightEnabled,
action: onRingLightTap
)
// Ring Color
ExpandedControlItem(
icon: "circle.fill",
label: "COLOR",
isActive: false,
action: onRingLightColorTap
)
.foregroundStyle(ringLightColor)
// Ring Size
ExpandedControlItem(
icon: "circle",
label: "SIZE",
isActive: false,
action: onRingLightSizeTap
)
// Ring Brightness
ExpandedControlItem(
icon: "circle.fill",
label: "BRIGHT",
isActive: false,
action: onRingLightOpacityTap
)
.foregroundStyle(Color.white.opacity(ringLightOpacity))
}
}
}
.padding(.top, Design.Spacing.small)
.padding(.bottom, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.small)
}
.frame(maxHeight: 400) // Limit max height to prevent excessive scrolling
.scrollIndicators(.hidden) // Hide scroll indicators for cleaner look
}
}
.padding(.horizontal, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: isExpanded ? Design.CornerRadius.large : collapsedHeight / 2)
.fill(Color.black.opacity(isExpanded ? Design.Opacity.strong : Design.Opacity.medium))
)
.animation(.easeInOut(duration: 0.25), value: isExpanded)
}
private var flashLabel: String {
switch flashMode {
case .off: return "FLASH"
case .auto: return "AUTO"
case .on: return "ON"
}
}
private var hdrLabel: String {
switch hdrMode {
case .off: return "HDR"
case .auto: return "AUTO"
case .on: return "ON"
}
}
}

View File

@ -0,0 +1,45 @@
//
// ExpandedControlItem.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import SwiftUI
import Bedrock
// MARK: - Expanded Control Item
struct ExpandedControlItem: View {
let icon: String
let label: String
let isActive: Bool
let action: () -> Void
// Layout constants
private let iconSize: CGFloat = 28
private let buttonSize: CGFloat = 56
private let labelFontSize: CGFloat = 10
var body: some View {
Button(action: action) {
VStack(spacing: Design.Spacing.xSmall) {
ZStack {
Circle()
.fill(isActive ? Color.yellow : Color.black.opacity(Design.Opacity.light))
.frame(width: buttonSize, height: buttonSize)
Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(isActive ? Color.black : Color.white)
}
Text(label)
.font(.system(size: labelFontSize, weight: .medium))
.foregroundStyle(isActive ? Color.yellow : Color.white)
}
}
.accessibilityLabel("\(label)")
.accessibilityValue(isActive ? "On" : "Off")
}
}

View File

@ -0,0 +1,163 @@
//
// PhotoReviewView.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import SwiftUI
import Bedrock
// MARK: - Photo Review View
struct PhotoReviewView: View {
let photo: CapturedPhoto
let isSaving: Bool
let saveError: String?
let onRetake: () -> Void
let onSave: () -> Void
var body: some View {
ZStack {
// Photo display
Color.black
.ignoresSafeArea()
Image(uiImage: photo.image)
.resizable()
.scaledToFit()
.ignoresSafeArea()
// Top toolbar
VStack {
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))
)
}
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))
)
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.large)
Spacer()
// Bottom action bar
VStack(spacing: Design.Spacing.medium) {
// Save status or error
if let error = saveError {
Text(error)
.foregroundColor(.red)
.font(.system(size: 14, weight: .medium))
.multilineTextAlignment(.center)
.padding(.vertical, Design.Spacing.small)
.padding(.horizontal, Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.red.opacity(0.2))
)
} else if isSaving {
HStack(spacing: Design.Spacing.small) {
ProgressView()
.tint(.white)
Text("Saving...")
.foregroundColor(.white)
.font(.system(size: 16, weight: .medium))
}
.padding(.vertical, Design.Spacing.small)
}
// Action buttons
HStack(spacing: Design.Spacing.xLarge) {
// Share button
ShareButton(photo: photo.image)
// Save button
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)
}
.disabled(isSaving)
}
.padding(.bottom, Design.Spacing.large)
}
.padding(.horizontal, Design.Spacing.large)
}
}
.accessibilityLabel("Photo review")
.accessibilityHint("Use the buttons at the bottom to save or share your photo")
}
}
// MARK: - Share Button
struct ShareButton: View {
let photo: UIImage
@State private var isShareSheetPresented = false
var body: some View {
Button(action: {
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)
}
.sheet(isPresented: $isShareSheetPresented) {
ShareSheet(activityItems: [photo])
}
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: activityItems,
applicationActivities: nil
)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No updates needed
}
}

View File

@ -0,0 +1,61 @@
//
// RingLightOverlay.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import SwiftUI
// MARK: - Ring Light Overlay
struct RingLightOverlay: View {
let color: Color
let width: CGFloat
let opacity: Double
var cornerRadius: CGFloat = 24
var body: some View {
// Use a filled rectangle with rounded rectangle cutout
// This ensures the inner edge has visible rounded corners
Rectangle()
.fill(color.opacity(opacity))
.reverseMask {
RoundedRectangle(cornerRadius: cornerRadius)
.padding(width) // Inset by the border width to create the ring effect
}
.ignoresSafeArea()
}
}
// MARK: - Corner Mask Overlay
/// Creates a black overlay with a rounded rectangle cutout to mask camera preview corners
struct CornerMaskOverlay: View {
let cornerRadius: CGFloat
var maskColor: Color = .black
var body: some View {
Rectangle()
.fill(maskColor)
.reverseMask {
RoundedRectangle(cornerRadius: cornerRadius)
}
}
}
// MARK: - Reverse Mask Extension
extension View {
@ViewBuilder
func reverseMask<Mask: View>(@ViewBuilder _ mask: () -> Mask) -> some View {
self.mask(
Rectangle()
.overlay(
mask()
.blendMode(.destinationOut)
)
.compositingGroup()
)
}
}

View File

@ -0,0 +1,55 @@
//
// ZoomControlView.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import SwiftUI
import Bedrock
// MARK: - Zoom Control View
struct ZoomControlView: View {
let zoomFactor: CGFloat
var isCenterStageActive: Bool = false
// Layout constants
private let fontSize: CGFloat = 16
private let iconSize: CGFloat = 14
var body: some View {
HStack {
Spacer()
if isCenterStageActive {
// Show Center Stage indicator instead of zoom level
// (pinch to zoom is disabled when Center Stage is active)
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "person.crop.rectangle.fill")
.font(.system(size: iconSize, weight: .medium))
Text("Center Stage")
.font(.system(size: fontSize, weight: .medium))
}
.foregroundStyle(Color.yellow)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.medium))
)
} else {
Text(String(format: "%.1fx", zoomFactor))
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(Color.white)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.medium))
)
}
Spacer()
}
.accessibilityLabel(isCenterStageActive ? "Center Stage active" : "Zoom \(String(format: "%.1f", zoomFactor)) times")
}
}

View File

@ -1,5 +1,6 @@
import SwiftUI
import Bedrock
import MijickCamera
struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel
@ -58,7 +59,38 @@ struct SettingsView: View {
isOn: $viewModel.isGridVisible
)
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
// Flash Mode
flashModePicker
// Flash Sync
SettingsToggle(
title: String(localized: "Flash Sync"),
subtitle: String(localized: "Use ring light color for flash"),
isOn: $viewModel.isFlashSyncedWithRingLight
)
.accessibilityHint(String(localized: "Syncs flash color with ring light color"))
// HDR Mode
hdrModePicker
// Photo Quality
photoQualityPicker
// Camera Position
cameraPositionPicker
// Ring Light Enabled
SettingsToggle(
title: String(localized: "Ring Light Enabled"),
subtitle: String(localized: "Show ring light around camera"),
isOn: $viewModel.isRingLightEnabled
)
.accessibilityHint(String(localized: "Enables or disables the ring light overlay"))
// Ring Light Brightness
ringLightBrightnessSlider
// Timer Selection
timerPicker
@ -204,8 +236,97 @@ struct SettingsView: View {
.padding(.vertical, Design.Spacing.xSmall)
}
// MARK: - Flash Mode Picker
private var flashModePicker: some View {
SegmentedPicker(
title: String(localized: "Flash Mode"),
options: CameraFlashMode.allCases.map { ($0.displayName, $0) },
selection: $viewModel.flashMode
)
.accessibilityLabel(String(localized: "Select flash mode"))
}
// MARK: - HDR Mode Picker
private var hdrModePicker: some View {
SegmentedPicker(
title: String(localized: "HDR Mode"),
options: CameraHDRMode.allCases.map { ($0.displayName, $0) },
selection: $viewModel.hdrMode
)
.accessibilityLabel(String(localized: "Select HDR mode"))
}
// MARK: - Photo Quality Picker
private var photoQualityPicker: some View {
SegmentedPicker(
title: String(localized: "Photo Quality"),
options: PhotoQuality.allCases.map { ($0.rawValue.capitalized, $0) },
selection: $viewModel.photoQuality
)
.accessibilityLabel(String(localized: "Select photo quality"))
}
// MARK: - Camera Position Picker
private var cameraPositionPicker: some View {
SegmentedPicker(
title: String(localized: "Camera"),
options: [
(String(localized: "Front"), CameraPosition.front),
(String(localized: "Back"), CameraPosition.back)
],
selection: $viewModel.cameraPosition
)
.accessibilityLabel(String(localized: "Select camera position"))
}
// MARK: - Ring Light Brightness Slider
private var ringLightBrightnessSlider: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(String(localized: "Ring Light Brightness"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(viewModel.ringLightOpacity * 100))%")
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "sun.min")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(
value: $viewModel.ringLightOpacity,
in: 0.1...1.0,
step: 0.05
)
.tint(Color.Accent.primary)
Image(systemName: "sun.max.fill")
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Text(String(localized: "Adjusts the brightness of the ring light"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(.vertical, Design.Spacing.xSmall)
.accessibilityLabel(String(localized: "Ring light brightness"))
.accessibilityValue("\(Int(viewModel.ringLightOpacity * 100)) percent")
}
// MARK: - Timer Picker
private var timerPicker: some View {
SegmentedPicker(
title: String(localized: "Self-Timer"),

View File

@ -1,5 +1,6 @@
import SwiftUI
import Bedrock
import MijickCamera
// MARK: - Timer Options
@ -198,6 +199,51 @@ final class SettingsViewModel: RingLightConfigurable {
get { CaptureMode(rawValue: cloudSync.data.selectedCaptureModeRaw) ?? .photo }
set { updateSettings { $0.selectedCaptureModeRaw = newValue.rawValue } }
}
// MARK: - Camera Settings
var flashMode: CameraFlashMode {
get { CameraFlashMode(rawValue: cloudSync.data.flashModeRaw) ?? .off }
set { updateSettings { $0.flashModeRaw = newValue.rawValue } }
}
var isFlashSyncedWithRingLight: Bool {
get { cloudSync.data.isFlashSyncedWithRingLight }
set { updateSettings { $0.isFlashSyncedWithRingLight = newValue } }
}
var hdrMode: CameraHDRMode {
get { CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off }
set { updateSettings { $0.hdrModeRaw = newValue.rawValue } }
}
var photoQuality: PhotoQuality {
get { PhotoQuality(rawValue: cloudSync.data.photoQualityRaw) ?? .high }
set { updateSettings { $0.photoQualityRaw = newValue.rawValue } }
}
var cameraPosition: CameraPosition {
get {
if cloudSync.data.cameraPositionRaw == "front" {
return .front
} else {
return .back
}
}
set {
updateSettings { $0.cameraPositionRaw = newValue == .front ? "front" : "back" }
}
}
var isRingLightEnabled: Bool {
get { cloudSync.data.isRingLightEnabled }
set { updateSettings { $0.isRingLightEnabled = newValue } }
}
var ringLightOpacity: Double {
get { cloudSync.data.ringLightOpacity }
set { updateSettings { $0.ringLightOpacity = newValue } }
}
var selectedLightColor: RingLightColor {
get { RingLightColor.fromId(lightColorId, customColor: customColor) }

View File

@ -1,10 +1,26 @@
{
"sourceLanguage" : "en",
"strings" : {
"%@" : {
"comment" : "A button with an icon and label. The argument is the text to display in the button.",
"isCommentAutoGenerated" : true
},
"%lld" : {
"comment" : "A text label displaying the currently selected ring light size. The text inside the label is replaced with the actual size value.",
"isCommentAutoGenerated" : true
},
"%lld percent" : {
"comment" : "The accessibility value of the ring light brightness slider, expressed as a percentage.",
"isCommentAutoGenerated" : true
},
"%lld points" : {
"comment" : "The value of the ring size slider, displayed in parentheses.",
"isCommentAutoGenerated" : true
},
"%lld%%" : {
"comment" : "A text label displaying the current brightness setting of the ring light, formatted as a percentage. The argument is the current brightness setting of the ring light, as a decimal between 0.0 and 1.",
"isCommentAutoGenerated" : true
},
"%lldpt" : {
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
"isCommentAutoGenerated" : true
@ -17,10 +33,22 @@
"comment" : "Description of a timer option when the timer is set to 5 seconds.",
"isCommentAutoGenerated" : true
},
"10%" : {
"comment" : "A label displayed alongside the left edge of the opacity slider.",
"isCommentAutoGenerated" : true
},
"10s" : {
"comment" : "Description of a timer option when the user selects \"10 seconds\".",
"isCommentAutoGenerated" : true
},
"100%" : {
"comment" : "A label displayed alongside the right edge of the opacity slider.",
"isCommentAutoGenerated" : true
},
"Adjusts the brightness of the ring light" : {
"comment" : "A description of the ring light brightness slider.",
"isCommentAutoGenerated" : true
},
"Adjusts the size of the light ring around the camera preview" : {
"comment" : "A description of the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
@ -49,6 +77,10 @@
"comment" : "A toggle option in the Settings view that allows the user to enable or disable automatic saving of captured photos and videos to the user's Photo Library.",
"isCommentAutoGenerated" : true
},
"Back" : {
"comment" : "Option in the camera position picker for using the back camera.",
"isCommentAutoGenerated" : true
},
"Best Value • Save 33%" : {
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
"isCommentAutoGenerated" : true
@ -56,6 +88,16 @@
"Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.",
"isCommentAutoGenerated" : true
},
"Camera" : {
"comment" : "Options for the camera position picker.",
"isCommentAutoGenerated" : true
},
"Camera controls" : {
},
"Camera Controls" : {
},
"Cancel" : {
"comment" : "The text for a button that dismisses the current view.",
@ -68,6 +110,13 @@
"Captured video" : {
"comment" : "A label describing a captured video.",
"isCommentAutoGenerated" : true
},
"Center Stage" : {
"comment" : "A label for the \"Center Stage\" button in the zoom control view.",
"isCommentAutoGenerated" : true
},
"Center Stage active" : {
},
"Close preview" : {
"comment" : "A button label that closes the preview screen.",
@ -101,6 +150,26 @@
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
"isCommentAutoGenerated" : true
},
"Double tap to capture a photo" : {
"comment" : "An accessibility hint for the capture button, instructing the user to double-tap it to capture a photo.",
"isCommentAutoGenerated" : true
},
"Enables or disables the ring light overlay" : {
"comment" : "A toggle that enables or disables the ring light overlay.",
"isCommentAutoGenerated" : true
},
"Flash Mode" : {
"comment" : "Title of a segmented picker that allows the user to select the flash mode of the camera.",
"isCommentAutoGenerated" : true
},
"Flash Sync" : {
"comment" : "Title of a toggle that synchronizes the flash color with the ring light color.",
"isCommentAutoGenerated" : true
},
"Front" : {
"comment" : "Option in the camera position picker for using the front camera.",
"isCommentAutoGenerated" : true
},
"Front Flash" : {
"comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.",
"isCommentAutoGenerated" : true
@ -113,6 +182,10 @@
"comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.",
"isCommentAutoGenerated" : true
},
"HDR Mode" : {
"comment" : "Title for a picker that allows the user to select the HDR mode of the camera.",
"isCommentAutoGenerated" : true
},
"Hides preview during capture for a flash effect" : {
"comment" : "Subtitle for the \"Front Flash\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
@ -140,6 +213,10 @@
"comment" : "The accessibility value for the grid toggle when it is off.",
"isCommentAutoGenerated" : true
},
"On" : {
"comment" : "A value that describes a control item as \"On\".",
"isCommentAutoGenerated" : true
},
"Open Source Licenses" : {
"comment" : "A heading displayed above a list of open source licenses used in the app.",
"isCommentAutoGenerated" : true
@ -150,6 +227,14 @@
},
"Photo" : {
},
"Photo Quality" : {
"comment" : "Title of a segmented picker that allows the user to select the photo quality.",
"isCommentAutoGenerated" : true
},
"Photo review" : {
"comment" : "The title of the view that lets users review and save or share a photo.",
"isCommentAutoGenerated" : true
},
"Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.",
@ -175,6 +260,29 @@
"comment" : "Title for a button that allows the user to retake a captured photo or video.",
"isCommentAutoGenerated" : true
},
"Ring Light" : {
},
"Ring light brightness" : {
"comment" : "An accessibility label for the ring light brightness setting in the settings view.",
"isCommentAutoGenerated" : true
},
"Ring Light Brightness" : {
"comment" : "The title of the overlay that appears when the user taps the ring light button.",
"isCommentAutoGenerated" : true
},
"Ring Light Color" : {
"comment" : "The title of the color picker overlay.",
"isCommentAutoGenerated" : true
},
"Ring Light Enabled" : {
"comment" : "Title of a toggle that enables or disables the ring light overlay.",
"isCommentAutoGenerated" : true
},
"Ring Light Size" : {
"comment" : "The title of the slider that allows the user to select the size of their ring light.",
"isCommentAutoGenerated" : true
},
"Ring size" : {
"comment" : "An accessibility label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
@ -191,6 +299,26 @@
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"isCommentAutoGenerated" : true
},
"Saving..." : {
"comment" : "A text that appears while a photo is being saved.",
"isCommentAutoGenerated" : true
},
"Select camera position" : {
"comment" : "A label describing the action of selecting a camera position.",
"isCommentAutoGenerated" : true
},
"Select flash mode" : {
"comment" : "An accessibility label for the flash mode picker in the settings view.",
"isCommentAutoGenerated" : true
},
"Select HDR mode" : {
"comment" : "A label describing the action of selecting an HDR mode in the settings view.",
"isCommentAutoGenerated" : true
},
"Select photo quality" : {
"comment" : "A label describing a segmented picker for selecting photo quality.",
"isCommentAutoGenerated" : true
},
"Select self-timer duration" : {
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
"isCommentAutoGenerated" : true
@ -207,6 +335,10 @@
"comment" : "Title for a button that shares the captured media.",
"isCommentAutoGenerated" : true
},
"Show ring light around camera" : {
"comment" : "Title of a toggle that enables or disables the ring light overlay.",
"isCommentAutoGenerated" : true
},
"Shows a grid overlay to help compose your shot" : {
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
"isCommentAutoGenerated" : true
@ -261,6 +393,20 @@
},
"Syncing..." : {
},
"Syncs flash color with ring light color" : {
"comment" : "A toggle that synchronizes the flash color with the ring light color.",
"isCommentAutoGenerated" : true
},
"Take photo" : {
"comment" : "An accessibility label for the capture button.",
"isCommentAutoGenerated" : true
},
"Tap to collapse settings" : {
},
"Tap to expand camera settings" : {
},
"Third-party libraries used in this app" : {
"comment" : "A description of the third-party libraries used in this app.",
@ -282,6 +428,14 @@
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true
},
"Use ring light color for flash" : {
"comment" : "Text for the \"Flash Sync\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"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.",
"isCommentAutoGenerated" : true
},
"Uses the ring light as a flash when taking photos" : {
"comment" : "An accessibility hint for the \"Front Flash\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
@ -309,6 +463,10 @@
"When enabled, the preview is not mirrored" : {
"comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.",
"isCommentAutoGenerated" : true
},
"Zoom %@ times" : {
"comment" : "A label describing the zoom level of the camera view. The argument is the string “%.1f”.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"

View File

@ -0,0 +1,44 @@
//
// Color+Codable.swift
// CameraTester
//
// Created by Matt Bruce on 1/3/26.
//
import SwiftUI
// MARK: - Color Codable Extension
extension Color: Codable {
enum CodingKeys: String, CodingKey {
case red, green, blue, opacity
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let red = try container.decode(Double.self, forKey: .red)
let green = try container.decode(Double.self, forKey: .green)
let blue = try container.decode(Double.self, forKey: .blue)
let opacity = try container.decode(Double.self, forKey: .opacity)
self.init(red: red, green: green, blue: blue, opacity: opacity)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
// Convert Color to RGB components
let uiColor = UIColor(self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
try container.encode(Double(red), forKey: .red)
try container.encode(Double(green), forKey: .green)
try container.encode(Double(blue), forKey: .blue)
try container.encode(Double(alpha), forKey: .opacity)
}
}

View File

@ -0,0 +1,49 @@
//
// CameraFlashMode.swift
// CameraTester
//
// Created by Matt Bruce on 1/3/26.
//
import Foundation
import MijickCamera
/// Camera flash mode options matching MijickCamera's CameraFlashMode
enum CameraFlashMode: String, CaseIterable, Codable {
case off
case on
case auto
var displayName: String {
switch self {
case .off: return "Off"
case .on: return "On"
case .auto: return "Auto"
}
}
var icon: String {
switch self {
case .off: return "bolt.slash"
case .on: return "bolt.fill"
case .auto: return "bolt.badge.automatic"
}
}
var description: String {
switch self {
case .off: return "Flash disabled"
case .on: return "Flash always on"
case .auto: return "Flash when needed"
}
}
/// Convert to MijickCamera's CameraFlashMode
var toMijickFlashMode: MijickCamera.CameraFlashMode {
switch self {
case .off: return .off
case .on: return .on
case .auto: return .auto
}
}
}

View File

@ -0,0 +1,50 @@
//
// CameraHDRMode.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import Foundation
import MijickCamera
/// Camera HDR mode options
enum CameraHDRMode: String, CaseIterable, Codable {
case off
case on
case auto
var displayName: String {
switch self {
case .off: return "Off"
case .on: return "On"
case .auto: return "Auto"
}
}
var description: String {
switch self {
case .off: return "HDR disabled"
case .on: return "HDR always on"
case .auto: return "HDR when needed"
}
}
/// Convert to MijickCamera.CameraHDRMode
var toMijickHDRMode: MijickCamera.CameraHDRMode {
switch self {
case .off: return .off
case .on: return .on
case .auto: return .auto
}
}
/// Initialize from MijickCamera.CameraHDRMode
init(from mijickMode: MijickCamera.CameraHDRMode) {
switch mijickMode {
case .off: self = .off
case .on: self = .on
case .auto: self = .auto
}
}
}

View File

@ -0,0 +1,70 @@
//
// CameraSettings.swift
// CameraTester
//
// Created by Matt Bruce on 1/3/26.
//
import SwiftUI
/// Persistent camera settings stored in UserDefaults
struct CameraSettings: Codable {
var photoQuality: PhotoQuality
var isRingLightEnabled: Bool
var ringLightColor: Color
var ringLightSize: CGFloat
var ringLightOpacity: Double
var flashMode: CameraFlashMode
var isFlashSyncedWithRingLight: Bool
// Default settings
static let `default` = CameraSettings(
photoQuality: .high,
isRingLightEnabled: true,
ringLightColor: .white,
ringLightSize: 25,
ringLightOpacity: 1.0,
flashMode: .off,
isFlashSyncedWithRingLight: false
)
// UserDefaults keys
private enum Keys {
static let cameraSettings = "cameraSettings"
}
// MARK: - Persistence
/// Save settings to UserDefaults
func save() {
let encoder = JSONEncoder()
do {
let data = try encoder.encode(self)
UserDefaults.standard.set(data, forKey: Keys.cameraSettings)
UserDefaults.standard.synchronize()
} catch {
print("Failed to save camera settings: \(error)")
}
}
/// Load settings from UserDefaults, or return defaults if none exist
static func load() -> CameraSettings {
guard let data = UserDefaults.standard.data(forKey: Keys.cameraSettings) else {
return .default
}
let decoder = JSONDecoder()
do {
return try decoder.decode(CameraSettings.self, from: data)
} catch {
print("Failed to load camera settings, using defaults: \(error)")
return .default
}
}
/// Reset settings to defaults and save
static func reset() {
let settings = CameraSettings.default
settings.save()
}
}

View File

@ -0,0 +1,15 @@
//
// CapturedPhoto.swift
// CameraTester
//
// Created by Matt Bruce on 1/3/26.
//
import SwiftUI
// Photo capture data
struct CapturedPhoto: Identifiable {
let id = UUID()
let image: UIImage
let timestamp: Date
}

View File

@ -0,0 +1,39 @@
//
// PhotoQuality.swift
// CameraTester
//
// Created by Matt Bruce on 1/3/26.
//
import SwiftUI
// Photo quality settings
enum PhotoQuality: String, CaseIterable, Codable {
case high = "High"
case medium = "Medium"
case low = "Low"
var compressionQuality: CGFloat {
switch self {
case .high: return 0.9 // 90% quality - best for sharing/printing
case .medium: return 0.75 // 75% quality - balanced
case .low: return 0.5 // 50% quality - small file size
}
}
var icon: String {
switch self {
case .high: return "star.fill"
case .medium: return "star.leadinghalf.filled"
case .low: return "star"
}
}
var description: String {
switch self {
case .high: return "High Quality"
case .medium: return "Medium Quality"
case .low: return "Low Quality"
}
}
}

View File

@ -0,0 +1,119 @@
//
// CameraProtocols.swift
// CameraTester
//
// Created by Matt Bruce on 1/2/26.
//
import AVFoundation
import SwiftUI
/// Protocol defining camera session management capabilities
protocol CameraSessionManaging: AnyObject {
var isSessionRunning: Bool { get }
func setupCamera() async
func startSession() async
func stopSession()
}
/// Protocol defining photo capture capabilities
protocol PhotoCapturing: AnyObject {
var capturedImage: UIImage? { get }
func takePhoto() async throws
}
/// Protocol defining camera permission handling
protocol CameraPermissionHandling: AnyObject {
func requestCameraPermission() async -> Bool
}
/// Protocol defining camera controls (flash, zoom, switch camera)
protocol CameraControllingAdvanced: AnyObject {
var currentCameraPosition: AVCaptureDevice.Position { get }
var isFlashAvailable: Bool { get }
var currentFlashMode: AVCaptureDevice.FlashMode { get }
var zoomFactor: CGFloat { get }
var maxZoomFactor: CGFloat { get }
func switchCamera() async
func setFlashMode(_ mode: AVCaptureDevice.FlashMode)
func setZoomFactor(_ factor: CGFloat)
}
/// Protocol defining Center Stage control capabilities
/// Center Stage automatically keeps users centered in the frame using the front camera
protocol CenterStageControlling {
/// Whether Center Stage is currently available on this device
var isCenterStageAvailable: Bool { get }
/// Whether Center Stage is currently enabled
var isCenterStageEnabled: Bool { get }
/// Enable or disable Center Stage
/// - Parameter enabled: Whether to enable Center Stage
/// - Throws: If Center Stage cannot be configured
func setCenterStageEnabled(_ enabled: Bool) throws
}
// MARK: - Center Stage Default Implementation
extension CenterStageControlling {
var isCenterStageAvailable: Bool {
AVCaptureDevice.isCenterStageEnabled || canEnableCenterStage
}
private var canEnableCenterStage: Bool {
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
return false
}
// Check if the device supports center stage by checking active format
return device.activeFormat.isCenterStageSupported
}
}
/// Combined protocol for full camera functionality
protocol CameraControlling: CameraSessionManaging, PhotoCapturing, CameraPermissionHandling, CameraControllingAdvanced {}
// MARK: - Default Implementations
extension CameraSessionManaging {
func startSession() async {
// Default implementation can be provided if needed
}
func stopSession() {
// Default implementation can be provided if needed
}
}
extension PhotoCapturing {
func takePhoto() async throws {
// Default implementation can be provided if needed
}
}
extension CameraPermissionHandling {
func requestCameraPermission() async -> Bool {
await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .video) { granted in
continuation.resume(returning: granted)
}
}
}
}
extension CameraControllingAdvanced {
func switchCamera() async {
// Default implementation can be provided if needed
}
func setFlashMode(_ mode: AVCaptureDevice.FlashMode) {
// Default implementation can be provided if needed
}
func setZoomFactor(_ factor: CGFloat) {
// Default implementation can be provided if needed
}
}

View File

@ -0,0 +1,59 @@
//
// PhotoLibraryService.swift
// CameraTester
//
// Created by Matt Bruce on 1/3/26.
//
import Photos
import SwiftUI
/// Errors that can occur during photo library operations
enum PhotoLibraryError: Error {
case accessDenied
case conversionFailed
case saveFailed(String)
var localizedDescription: String {
switch self {
case .accessDenied:
return "Photos access denied. Please enable in Settings."
case .conversionFailed:
return "Failed to convert image to JPEG format"
case .saveFailed(let message):
return "Failed to save photo: \(message)"
}
}
}
/// Service for handling photo library operations
@MainActor
class PhotoLibraryService {
/// Save a photo to the user's photo library
/// - Parameters:
/// - image: The UIImage to save
/// - quality: The photo quality setting for JPEG compression
/// - Returns: Result indicating success or error
static func savePhotoToLibrary(_ image: UIImage, quality: PhotoQuality) async -> Result<Void, PhotoLibraryError> {
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard status == .authorized || status == .limited else {
return .failure(.accessDenied)
}
// Convert image to JPEG data using selected quality
guard let imageData = image.jpegData(compressionQuality: quality.compressionQuality) else {
return .failure(.conversionFailed)
}
do {
try await PHPhotoLibrary.shared().performChanges {
let options = PHAssetResourceCreationOptions()
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: imageData, options: options)
}
return .success(())
} catch {
return .failure(.saveFailed(error.localizedDescription))
}
}
}

View File

@ -63,6 +63,27 @@ struct SyncedSettings: PersistableData, Sendable {
/// Whether captures are auto-saved to Photo Library
var isAutoSaveEnabled: Bool = true
/// Flash mode raw value
var flashModeRaw: String = "off"
/// Whether flash is synced with ring light color
var isFlashSyncedWithRingLight: Bool = false
/// HDR mode raw value
var hdrModeRaw: String = "off"
/// Photo quality raw value
var photoQualityRaw: String = "high"
/// Camera position raw value
var cameraPositionRaw: String = "front"
/// Whether ring light is enabled
var isRingLightEnabled: Bool = true
/// Ring light opacity (brightness)
var ringLightOpacity: Double = 1.0
// MARK: - Computed Properties
@ -119,6 +140,13 @@ struct SyncedSettings: PersistableData, Sendable {
case currentZoomFactor
case selectedCaptureModeRaw
case isAutoSaveEnabled
case flashModeRaw
case isFlashSyncedWithRingLight
case hdrModeRaw
case photoQualityRaw
case cameraPositionRaw
case isRingLightEnabled
case ringLightOpacity
}
}
@ -138,6 +166,13 @@ extension SyncedSettings: Equatable {
lhs.isGridVisible == rhs.isGridVisible &&
lhs.currentZoomFactor == rhs.currentZoomFactor &&
lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw &&
lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled
lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled &&
lhs.flashModeRaw == rhs.flashModeRaw &&
lhs.isFlashSyncedWithRingLight == rhs.isFlashSyncedWithRingLight &&
lhs.hdrModeRaw == rhs.hdrModeRaw &&
lhs.photoQualityRaw == rhs.photoQualityRaw &&
lhs.cameraPositionRaw == rhs.cameraPositionRaw &&
lhs.isRingLightEnabled == rhs.isRingLightEnabled &&
lhs.ringLightOpacity == rhs.ringLightOpacity
}
}