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

This commit is contained in:
Matt Bruce 2026-01-04 11:34:49 -06:00
parent 915088f180
commit eedd709ef5
2 changed files with 51 additions and 137 deletions

View File

@ -2,47 +2,7 @@ import SwiftUI
import MijickCamera import MijickCamera
import Bedrock import Bedrock
// 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 { struct ContentView: View {
@State private var settings = SettingsViewModel() @State private var settings = SettingsViewModel()
@ -55,8 +15,7 @@ struct ContentView: View {
@State private var isSavingPhoto = false @State private var isSavingPhoto = false
@State private var saveError: String? @State private var saveError: String?
/// Camera settings in a shared observable to prevent MCamera recreation /// Settings are managed by SettingsViewModel which is already @Observable
@State private var cameraSettings: CameraSettingsState?
/// Unique key to force MCamera recreation after photo capture /// Unique key to force MCamera recreation after photo capture
/// Incrementing this value creates a new camera session with fresh AVCapturePhotoOutput /// Incrementing this value creates a new camera session with fresh AVCapturePhotoOutput
@ -67,7 +26,7 @@ struct ContentView: View {
// Camera view - only recreates when sessionKey changes due to Equatable // Camera view - only recreates when sessionKey changes due to Equatable
if !showPhotoReview { if !showPhotoReview {
CameraContainerView( CameraContainerView(
cameraSettings: cameraSettings ?? CameraSettingsState(settings: settings), settings: settings,
sessionKey: cameraSessionKey, sessionKey: cameraSessionKey,
onImageCaptured: { image in onImageCaptured: { image in
capturedPhoto = CapturedPhoto(image: image, timestamp: Date()) capturedPhoto = CapturedPhoto(image: image, timestamp: Date())
@ -120,29 +79,16 @@ struct ContentView: View {
} }
.ignoresSafeArea() .ignoresSafeArea()
.animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview) .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 .onChange(of: settings.isRingLightEnabled) { _, newValue in
updateCameraSettings()
if settings.isFlashSyncedWithRingLight { if settings.isFlashSyncedWithRingLight {
settings.flashMode = newValue ? .on : .off 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 .onChange(of: settings.isFlashSyncedWithRingLight) { _, newValue in
updateCameraSettings()
if newValue { if newValue {
settings.flashMode = settings.isRingLightEnabled ? .on : .off 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) { .sheet(isPresented: $showSettings) {
SettingsView(viewModel: settings, showPaywall: $showPaywall) SettingsView(viewModel: settings, showPaywall: $showPaywall)
} }
@ -151,10 +97,6 @@ struct ContentView: View {
} }
} }
private func updateCameraSettings() {
cameraSettings?.update(from: settings)
}
/// Resets state and regenerates camera session key to create a fresh camera instance /// Resets state and regenerates camera session key to create a fresh camera instance
private func resetCameraForNextCapture() { private func resetCameraForNextCapture() {
capturedPhoto = nil capturedPhoto = nil
@ -169,7 +111,7 @@ struct ContentView: View {
isSavingPhoto = true isSavingPhoto = true
saveError = nil saveError = nil
let quality = cameraSettings?.photoQuality ?? .high let quality = settings.photoQuality
Task { Task {
let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality) let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality)
@ -199,7 +141,7 @@ struct ContentView: View {
// MARK: - Camera Container View // MARK: - Camera Container View
/// Wrapper view for MCamera - only recreates when sessionKey changes /// Wrapper view for MCamera - only recreates when sessionKey changes
struct CameraContainerView: View, Equatable { struct CameraContainerView: View, Equatable {
let cameraSettings: CameraSettingsState let settings: SettingsViewModel
let sessionKey: UUID let sessionKey: UUID
let onImageCaptured: (UIImage) -> Void let onImageCaptured: (UIImage) -> Void
@ -216,12 +158,12 @@ struct CameraContainerView: View, Equatable {
cameraManager: cameraManager, cameraManager: cameraManager,
namespace: namespace, namespace: namespace,
closeMCameraAction: closeAction, closeMCameraAction: closeAction,
cameraSettings: cameraSettings, cameraSettings: settings,
onPhotoCaptured: onImageCaptured onPhotoCaptured: onImageCaptured
) )
} }
.setCapturedMediaScreen(nil) .setCapturedMediaScreen(nil)
.setCameraPosition(cameraSettings.cameraPosition) .setCameraPosition(settings.cameraPosition)
.startSession() .startSession()
} }
} }

View File

@ -18,40 +18,10 @@ struct CustomCameraScreen: MCameraScreen {
let closeMCameraAction: () -> () let closeMCameraAction: () -> ()
/// Shared camera settings state - using Observable class prevents MCamera recreation /// Shared camera settings state - using Observable class prevents MCamera recreation
var cameraSettings: CameraSettingsState var cameraSettings: SettingsViewModel
/// Callback when photo is captured - bypasses MijickCamera's callback system /// Callback when photo is captured - bypasses MijickCamera's callback system
var onPhotoCaptured: ((UIImage) -> Void)? 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 // Center Stage state
@State private var isCenterStageEnabled: Bool = AVCaptureDevice.isCenterStageEnabled @State private var isCenterStageEnabled: Bool = AVCaptureDevice.isCenterStageEnabled
@ -98,9 +68,9 @@ struct CustomCameraScreen: MCameraScreen {
// Ring light overlay - covers corners and creates rounded inner edge // Ring light overlay - covers corners and creates rounded inner edge
// When ring light is off, still show black corners to maintain rounded appearance // When ring light is off, still show black corners to maintain rounded appearance
RingLightOverlay( RingLightOverlay(
color: isRingLightEnabled ? ringLightColor : .black, color: cameraSettings.isRingLightEnabled ? cameraSettings.lightColor : .black,
width: isRingLightEnabled ? ringLightSize : Design.CornerRadius.large, width: cameraSettings.isRingLightEnabled ? cameraSettings.ringSize : Design.CornerRadius.large,
opacity: isRingLightEnabled ? ringLightOpacity : 1.0, opacity: cameraSettings.isRingLightEnabled ? cameraSettings.ringLightOpacity : 1.0,
cornerRadius: Design.CornerRadius.large cornerRadius: Design.CornerRadius.large
) )
.allowsHitTesting(false) // Allow touches to pass through to camera view .allowsHitTesting(false) // Allow touches to pass through to camera view
@ -119,31 +89,31 @@ struct CustomCameraScreen: MCameraScreen {
isExpanded: $isControlsExpanded, isExpanded: $isControlsExpanded,
hasActiveSettings: hasActiveSettings, hasActiveSettings: hasActiveSettings,
activeSettingsIcons: activeSettingsIcons, activeSettingsIcons: activeSettingsIcons,
flashMode: flashMode, flashMode: cameraSettings.flashMode,
flashIcon: flashIcon, flashIcon: flashIcon,
onFlashTap: toggleFlash, onFlashTap: toggleFlash,
isFlashSyncedWithRingLight: isFlashSyncedWithRingLight, isFlashSyncedWithRingLight: cameraSettings.isFlashSyncedWithRingLight,
onFlashSyncTap: toggleFlashSync, onFlashSyncTap: toggleFlashSync,
hdrMode: cameraSettings.hdrMode, hdrMode: cameraSettings.hdrMode,
hdrIcon: hdrIcon, hdrIcon: hdrIcon,
onHDRTap: toggleHDR, onHDRTap: toggleHDR,
isGridVisible: isGridVisible, isGridVisible: cameraSettings.isGridVisible,
gridIcon: gridIcon, gridIcon: gridIcon,
onGridTap: toggleGrid, onGridTap: toggleGrid,
photoQuality: photoQuality, photoQuality: cameraSettings.photoQuality,
onQualityTap: cycleQuality, onQualityTap: cycleQuality,
isCenterStageAvailable: isCenterStageAvailable, isCenterStageAvailable: isCenterStageAvailable,
isCenterStageEnabled: isCenterStageEnabled, isCenterStageEnabled: isCenterStageEnabled,
onCenterStageTap: toggleCenterStage, onCenterStageTap: toggleCenterStage,
isFrontCamera: cameraPosition == .front, isFrontCamera: cameraPosition == .front,
onFlipCameraTap: flipCamera, onFlipCameraTap: flipCamera,
isRingLightEnabled: isRingLightEnabled, isRingLightEnabled: cameraSettings.isRingLightEnabled,
onRingLightTap: toggleRingLight, onRingLightTap: toggleRingLight,
ringLightColor: ringLightColor, ringLightColor: cameraSettings.lightColor,
onRingLightColorTap: toggleRingLightColorPicker, onRingLightColorTap: toggleRingLightColorPicker,
ringLightSize: ringLightSize, ringLightSize: cameraSettings.ringSize,
onRingLightSizeTap: toggleRingLightSizeSlider, onRingLightSizeTap: toggleRingLightSizeSlider,
ringLightOpacity: ringLightOpacity, ringLightOpacity: cameraSettings.ringLightOpacity,
onRingLightOpacityTap: toggleRingLightOpacitySlider onRingLightOpacityTap: toggleRingLightOpacitySlider
) )
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
@ -169,31 +139,31 @@ struct CustomCameraScreen: MCameraScreen {
isExpanded: $isControlsExpanded, isExpanded: $isControlsExpanded,
hasActiveSettings: hasActiveSettings, hasActiveSettings: hasActiveSettings,
activeSettingsIcons: activeSettingsIcons, activeSettingsIcons: activeSettingsIcons,
flashMode: flashMode, flashMode: cameraSettings.flashMode,
flashIcon: flashIcon, flashIcon: flashIcon,
onFlashTap: toggleFlash, onFlashTap: toggleFlash,
isFlashSyncedWithRingLight: isFlashSyncedWithRingLight, isFlashSyncedWithRingLight: cameraSettings.isFlashSyncedWithRingLight,
onFlashSyncTap: toggleFlashSync, onFlashSyncTap: toggleFlashSync,
hdrMode: cameraSettings.hdrMode, hdrMode: cameraSettings.hdrMode,
hdrIcon: hdrIcon, hdrIcon: hdrIcon,
onHDRTap: toggleHDR, onHDRTap: toggleHDR,
isGridVisible: isGridVisible, isGridVisible: cameraSettings.isGridVisible,
gridIcon: gridIcon, gridIcon: gridIcon,
onGridTap: toggleGrid, onGridTap: toggleGrid,
photoQuality: photoQuality, photoQuality: cameraSettings.photoQuality,
onQualityTap: cycleQuality, onQualityTap: cycleQuality,
isCenterStageAvailable: isCenterStageAvailable, isCenterStageAvailable: isCenterStageAvailable,
isCenterStageEnabled: isCenterStageEnabled, isCenterStageEnabled: isCenterStageEnabled,
onCenterStageTap: toggleCenterStage, onCenterStageTap: toggleCenterStage,
isFrontCamera: cameraPosition == .front, isFrontCamera: cameraPosition == .front,
onFlipCameraTap: flipCamera, onFlipCameraTap: flipCamera,
isRingLightEnabled: isRingLightEnabled, isRingLightEnabled: cameraSettings.isRingLightEnabled,
onRingLightTap: toggleRingLight, onRingLightTap: toggleRingLight,
ringLightColor: ringLightColor, ringLightColor: cameraSettings.lightColor,
onRingLightColorTap: toggleRingLightColorPicker, onRingLightColorTap: toggleRingLightColorPicker,
ringLightSize: ringLightSize, ringLightSize: cameraSettings.ringSize,
onRingLightSizeTap: toggleRingLightSizeSlider, onRingLightSizeTap: toggleRingLightSizeSlider,
ringLightOpacity: ringLightOpacity, ringLightOpacity: cameraSettings.ringLightOpacity,
onRingLightOpacityTap: toggleRingLightOpacitySlider onRingLightOpacityTap: toggleRingLightOpacitySlider
) )
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
@ -222,8 +192,8 @@ struct CustomCameraScreen: MCameraScreen {
if showRingLightColorPicker { if showRingLightColorPicker {
ColorPickerOverlay( ColorPickerOverlay(
selectedColor: Binding( selectedColor: Binding(
get: { cameraSettings.ringLightColor }, get: { cameraSettings.selectedLightColor.color },
set: { cameraSettings.ringLightColor = $0 } set: { cameraSettings.selectedLightColor = RingLightColor.custom(with: $0) }
), ),
isPresented: $showRingLightColorPicker isPresented: $showRingLightColorPicker
) )
@ -234,8 +204,8 @@ struct CustomCameraScreen: MCameraScreen {
if showRingLightSizeSlider { if showRingLightSizeSlider {
SizeSliderOverlay( SizeSliderOverlay(
selectedSize: Binding( selectedSize: Binding(
get: { cameraSettings.ringLightSize }, get: { cameraSettings.ringSize },
set: { cameraSettings.ringLightSize = $0 } set: { cameraSettings.ringSize = $0 }
), ),
isPresented: $showRingLightSizeSlider isPresented: $showRingLightSizeSlider
) )
@ -290,7 +260,7 @@ struct CustomCameraScreen: MCameraScreen {
// Initialize zoom gesture state // Initialize zoom gesture state
lastMagnification = zoomFactor lastMagnification = zoomFactor
} }
.onChange(of: isFlashSyncedWithRingLight) { _, _ in .onChange(of: cameraSettings.isFlashSyncedWithRingLight) { _, _ in
// Only update when sync setting changes, not on every color change // Only update when sync setting changes, not on every color change
updateFlashSyncState() updateFlashSyncState()
} }
@ -309,22 +279,22 @@ struct CustomCameraScreen: MCameraScreen {
/// Returns true if any setting is in a non-default state /// Returns true if any setting is in a non-default state
private var hasActiveSettings: Bool { private var hasActiveSettings: Bool {
flashMode != .off || cameraSettings.hdrMode != .off || isGridVisible || isCenterStageEnabled || isRingLightEnabled cameraSettings.flashMode != .off || cameraSettings.hdrMode != .off || cameraSettings.isGridVisible || isCenterStageEnabled || cameraSettings.isRingLightEnabled
} }
/// Returns icons for currently active settings (for collapsed pill display) /// Returns icons for currently active settings (for collapsed pill display)
private var activeSettingsIcons: [String] { private var activeSettingsIcons: [String] {
var icons: [String] = [] var icons: [String] = []
if flashMode != .off { if cameraSettings.flashMode != .off {
icons.append(flashIcon) icons.append(flashIcon)
} }
if cameraSettings.hdrMode != .off { if cameraSettings.hdrMode != .off {
icons.append(hdrIcon) icons.append(hdrIcon)
} }
if isGridVisible { if cameraSettings.isGridVisible {
icons.append(gridIcon) icons.append(gridIcon)
} }
if isRingLightEnabled { if cameraSettings.isRingLightEnabled {
icons.append("circle.fill") icons.append("circle.fill")
} }
if isCenterStageEnabled { if isCenterStageEnabled {
@ -335,7 +305,7 @@ struct CustomCameraScreen: MCameraScreen {
// MARK: - Control Icons // MARK: - Control Icons
private var flashIcon: String { private var flashIcon: String {
flashMode.icon cameraSettings.flashMode.icon
} }
private var hdrIcon: String { private var hdrIcon: String {
@ -347,7 +317,7 @@ struct CustomCameraScreen: MCameraScreen {
} }
private var gridIcon: String { private var gridIcon: String {
isGridVisible ? "grid" : "grid" cameraSettings.isGridVisible ? "grid" : "grid"
} }
private var isCenterStageAvailable: Bool { private var isCenterStageAvailable: Bool {
@ -360,18 +330,18 @@ struct CustomCameraScreen: MCameraScreen {
// MARK: - Actions // MARK: - Actions
private func toggleFlash() { private func toggleFlash() {
let nextMode: CameraFlashMode let nextMode: CameraFlashMode
switch flashMode { switch cameraSettings.flashMode {
case .off: nextMode = .auto case .off: nextMode = .auto
case .auto: nextMode = .on case .auto: nextMode = .on
case .on: nextMode = .off case .on: nextMode = .off
} }
flashMode = nextMode cameraSettings.flashMode = nextMode
// Update MijickCamera's flash mode so it knows to use iOS Retina Flash // Update MijickCamera's flash mode so it knows to use iOS Retina Flash
setFlashMode(nextMode.toMijickFlashMode) setFlashMode(nextMode.toMijickFlashMode)
} }
private func toggleFlashSync() { private func toggleFlashSync() {
isFlashSyncedWithRingLight.toggle() cameraSettings.isFlashSyncedWithRingLight.toggle()
} }
private func toggleHDR() { private func toggleHDR() {
@ -392,7 +362,8 @@ struct CustomCameraScreen: MCameraScreen {
} }
private func toggleGrid() { private func toggleGrid() {
setGridVisibility(!isGridVisible) cameraSettings.isGridVisible.toggle()
setGridVisibility(cameraSettings.isGridVisible)
} }
private func flipCamera() { private func flipCamera() {
@ -400,6 +371,7 @@ struct CustomCameraScreen: MCameraScreen {
do { do {
let newPosition: CameraPosition = (cameraPosition == .front) ? .back : .front let newPosition: CameraPosition = (cameraPosition == .front) ? .back : .front
try await setCameraPosition(newPosition) try await setCameraPosition(newPosition)
cameraSettings.cameraPosition = newPosition
} catch { } catch {
print("Failed to flip camera: \(error)") print("Failed to flip camera: \(error)")
} }
@ -442,34 +414,34 @@ struct CustomCameraScreen: MCameraScreen {
private func cycleQuality() { private func cycleQuality() {
let allCases = PhotoQuality.allCases let allCases = PhotoQuality.allCases
let currentIndex = allCases.firstIndex(of: photoQuality) ?? 0 let currentIndex = allCases.firstIndex(of: cameraSettings.photoQuality) ?? 0
let nextIndex = (currentIndex + 1) % allCases.count let nextIndex = (currentIndex + 1) % allCases.count
photoQuality = allCases[nextIndex] cameraSettings.photoQuality = allCases[nextIndex]
} }
private func toggleRingLight() { private func toggleRingLight() {
isRingLightEnabled.toggle() cameraSettings.isRingLightEnabled.toggle()
} }
private func updateFlashSyncState() { private func updateFlashSyncState() {
// Tell MijickCamera whether we're handling flash ourselves (sync enabled) // Tell MijickCamera whether we're handling flash ourselves (sync enabled)
// or if iOS should handle it (sync disabled) // or if iOS should handle it (sync disabled)
// We use .white as a placeholder - the actual color comes from ringLightColor in SwiftUI // We use .white as a placeholder - the actual color comes from ringLightColor in SwiftUI
if isFlashSyncedWithRingLight { if cameraSettings.isFlashSyncedWithRingLight {
setScreenFlashColor(.white) // Non-nil = we handle flash, disable iOS flash setScreenFlashColor(.white) // Non-nil = we handle flash, disable iOS flash
} else { } else {
setScreenFlashColor(nil) // nil = let iOS handle Retina Flash setScreenFlashColor(nil) // nil = let iOS handle Retina Flash
} }
} }
/// The color to use for screen flash overlay /// The color to use for screen flash overlay
private var screenFlashColor: Color { private var screenFlashColor: Color {
isFlashSyncedWithRingLight ? ringLightColor : .white cameraSettings.isFlashSyncedWithRingLight ? cameraSettings.lightColor : .white
} }
/// Whether to use custom screen flash (front camera + flash on + sync enabled) /// Whether to use custom screen flash (front camera + flash on + sync enabled)
private var shouldUseCustomScreenFlash: Bool { private var shouldUseCustomScreenFlash: Bool {
cameraPosition == .front && flashMode != .off && isFlashSyncedWithRingLight cameraPosition == .front && cameraSettings.flashMode != .off && cameraSettings.isFlashSyncedWithRingLight
} }
/// Performs capture with screen flash if needed /// Performs capture with screen flash if needed