diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd828c2..a28a441 100644 --- a/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SelfieCam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, { diff --git a/SelfieCam/Features/Camera/ContentView.swift b/SelfieCam/Features/Camera/ContentView.swift index 737d668..86f6ed7 100644 --- a/SelfieCam/Features/Camera/ContentView.swift +++ b/SelfieCam/Features/Camera/ContentView.swift @@ -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() } } diff --git a/SelfieCam/Features/Camera/PostCapturePreviewView.swift b/SelfieCam/Features/Camera/PostCapturePreviewView.swift index 248be98..a8195db 100644 --- a/SelfieCam/Features/Camera/PostCapturePreviewView.swift +++ b/SelfieCam/Features/Camera/PostCapturePreviewView.swift @@ -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( diff --git a/SelfieCam/Features/Camera/Views/CaptureButton.swift b/SelfieCam/Features/Camera/Views/CaptureButton.swift new file mode 100644 index 0000000..62c4ff7 --- /dev/null +++ b/SelfieCam/Features/Camera/Views/CaptureButton.swift @@ -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") + } +} diff --git a/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift new file mode 100644 index 0000000..b66bd9a --- /dev/null +++ b/SelfieCam/Features/Camera/Views/CustomCameraScreen.swift @@ -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) + } + } +} diff --git a/SelfieCam/Features/Camera/Views/ExpandableControlsPanel.swift b/SelfieCam/Features/Camera/Views/ExpandableControlsPanel.swift new file mode 100644 index 0000000..2df51f6 --- /dev/null +++ b/SelfieCam/Features/Camera/Views/ExpandableControlsPanel.swift @@ -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" + } + } +} diff --git a/SelfieCam/Features/Camera/Views/ExpandedControlItem.swift b/SelfieCam/Features/Camera/Views/ExpandedControlItem.swift new file mode 100644 index 0000000..4186cdf --- /dev/null +++ b/SelfieCam/Features/Camera/Views/ExpandedControlItem.swift @@ -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") + } +} diff --git a/SelfieCam/Features/Camera/Views/PhotoReviewView.swift b/SelfieCam/Features/Camera/Views/PhotoReviewView.swift new file mode 100644 index 0000000..0011f68 --- /dev/null +++ b/SelfieCam/Features/Camera/Views/PhotoReviewView.swift @@ -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 + } +} diff --git a/SelfieCam/Features/Camera/Views/RingLightOverlay.swift b/SelfieCam/Features/Camera/Views/RingLightOverlay.swift new file mode 100644 index 0000000..34c1d03 --- /dev/null +++ b/SelfieCam/Features/Camera/Views/RingLightOverlay.swift @@ -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(@ViewBuilder _ mask: () -> Mask) -> some View { + self.mask( + Rectangle() + .overlay( + mask() + .blendMode(.destinationOut) + ) + .compositingGroup() + ) + } +} diff --git a/SelfieCam/Features/Camera/Views/ZoomControlView.swift b/SelfieCam/Features/Camera/Views/ZoomControlView.swift new file mode 100644 index 0000000..78378ac --- /dev/null +++ b/SelfieCam/Features/Camera/Views/ZoomControlView.swift @@ -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") + } +} diff --git a/SelfieCam/Features/Settings/SettingsView.swift b/SelfieCam/Features/Settings/SettingsView.swift index 4510ec1..b462f8f 100644 --- a/SelfieCam/Features/Settings/SettingsView.swift +++ b/SelfieCam/Features/Settings/SettingsView.swift @@ -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"), diff --git a/SelfieCam/Features/Settings/SettingsViewModel.swift b/SelfieCam/Features/Settings/SettingsViewModel.swift index 1586b5a..a1138d1 100644 --- a/SelfieCam/Features/Settings/SettingsViewModel.swift +++ b/SelfieCam/Features/Settings/SettingsViewModel.swift @@ -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) } diff --git a/SelfieCam/Resources/Localizable.xcstrings b/SelfieCam/Resources/Localizable.xcstrings index 71bb860..e0dd7f5 100644 --- a/SelfieCam/Resources/Localizable.xcstrings +++ b/SelfieCam/Resources/Localizable.xcstrings @@ -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" diff --git a/SelfieCam/Shared/Extensions/Color+Codable.swift b/SelfieCam/Shared/Extensions/Color+Codable.swift new file mode 100644 index 0000000..7544308 --- /dev/null +++ b/SelfieCam/Shared/Extensions/Color+Codable.swift @@ -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) + } +} diff --git a/SelfieCam/Shared/Models/CameraFlashMode.swift b/SelfieCam/Shared/Models/CameraFlashMode.swift new file mode 100644 index 0000000..4874e78 --- /dev/null +++ b/SelfieCam/Shared/Models/CameraFlashMode.swift @@ -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 + } + } +} diff --git a/SelfieCam/Shared/Models/CameraHDRMode.swift b/SelfieCam/Shared/Models/CameraHDRMode.swift new file mode 100644 index 0000000..a956dc0 --- /dev/null +++ b/SelfieCam/Shared/Models/CameraHDRMode.swift @@ -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 + } + } +} diff --git a/SelfieCam/Shared/Models/CameraSettings.swift b/SelfieCam/Shared/Models/CameraSettings.swift new file mode 100644 index 0000000..2058f59 --- /dev/null +++ b/SelfieCam/Shared/Models/CameraSettings.swift @@ -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() + } +} diff --git a/SelfieCam/Shared/Models/CapturedPhoto.swift b/SelfieCam/Shared/Models/CapturedPhoto.swift new file mode 100644 index 0000000..911ce29 --- /dev/null +++ b/SelfieCam/Shared/Models/CapturedPhoto.swift @@ -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 +} diff --git a/SelfieCam/Shared/Models/PhotoQuality.swift b/SelfieCam/Shared/Models/PhotoQuality.swift new file mode 100644 index 0000000..4bddb8f --- /dev/null +++ b/SelfieCam/Shared/Models/PhotoQuality.swift @@ -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" + } + } +} diff --git a/SelfieCam/Shared/Protocols/CameraProtocols.swift b/SelfieCam/Shared/Protocols/CameraProtocols.swift new file mode 100644 index 0000000..f431d85 --- /dev/null +++ b/SelfieCam/Shared/Protocols/CameraProtocols.swift @@ -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 + } +} diff --git a/SelfieCam/Shared/Services/PhotoLibraryService.swift b/SelfieCam/Shared/Services/PhotoLibraryService.swift new file mode 100644 index 0000000..3f52761 --- /dev/null +++ b/SelfieCam/Shared/Services/PhotoLibraryService.swift @@ -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 { + 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)) + } + } +} diff --git a/SelfieCam/Shared/Storage/SyncedSettings.swift b/SelfieCam/Shared/Storage/SyncedSettings.swift index 2626717..d01c9b8 100644 --- a/SelfieCam/Shared/Storage/SyncedSettings.swift +++ b/SelfieCam/Shared/Storage/SyncedSettings.swift @@ -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 } }