From 6e1ce6d262c50b8c75b139233b04c996e5a8f653 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 2 Jan 2026 16:26:41 -0600 Subject: [PATCH] Revert to custom camera implementation - MijickCamera not working MijickCamera wasn't rendering the camera preview properly in our view hierarchy. Reverting to our custom AVFoundation-based camera implementation which was working correctly. Reverted: - Restored CameraViewModel.swift - Restored CameraPreview.swift - Restored ContentView.swift to pre-MijickCamera version - Removed MijickCamera package dependency Kept: - Open Source Licenses section (now just shows RevenueCat) - All other features and fixes Our custom camera code handles: - Camera preview with proper orientation - Photo/video capture - Front flash effect - Center Stage support - Manual rotation - Zoom gestures --- SelfieRingLight.xcodeproj/project.pbxproj | 12 - .../Features/Camera/CameraPreview.swift | 233 ++++++++ .../Features/Camera/CameraViewModel.swift | 393 ++++++++++++++ .../Features/Camera/ContentView.swift | 500 +++++++++++------- 4 files changed, 949 insertions(+), 189 deletions(-) create mode 100644 SelfieRingLight/Features/Camera/CameraPreview.swift create mode 100644 SelfieRingLight/Features/Camera/CameraViewModel.swift diff --git a/SelfieRingLight.xcodeproj/project.pbxproj b/SelfieRingLight.xcodeproj/project.pbxproj index 9dab21d..e4714f7 100644 --- a/SelfieRingLight.xcodeproj/project.pbxproj +++ b/SelfieRingLight.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C852F08306200DC03E1 /* RevenueCat */; }; EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C872F08306200DC03E1 /* RevenueCatUI */; }; EA766F022F08500000DC03E1 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F012F08500000DC03E1 /* Bedrock */; }; - EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F0F2F08600000DC03E1 /* MijickCamera */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,7 +63,6 @@ EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */, EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */, EA766F022F08500000DC03E1 /* Bedrock in Frameworks */, - EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,7 +136,6 @@ EA766C852F08306200DC03E1 /* RevenueCat */, EA766C872F08306200DC03E1 /* RevenueCatUI */, EA766F012F08500000DC03E1 /* Bedrock */, - EA766F0F2F08600000DC03E1 /* MijickCamera */, ); productName = SelfieRingLight; productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */; @@ -225,7 +222,6 @@ packageReferences = ( EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */, EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */, - EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */, ); preferredProjectObjectVersion = 77; productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */; @@ -642,14 +638,6 @@ kind = branch; }; }; - EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Mijick/Camera"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.0.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ diff --git a/SelfieRingLight/Features/Camera/CameraPreview.swift b/SelfieRingLight/Features/Camera/CameraPreview.swift new file mode 100644 index 0000000..126d2c1 --- /dev/null +++ b/SelfieRingLight/Features/Camera/CameraPreview.swift @@ -0,0 +1,233 @@ +import SwiftUI +import UIKit +import AVFoundation + +struct CameraPreview: UIViewRepresentable { + let viewModel: CameraViewModel + + // These properties trigger view updates when they change + var isMirrorFlipped: Bool + var zoomFactor: Double + var manualRotationAngle: CGFloat + var ringLightColor: Color + + init(viewModel: CameraViewModel, isMirrorFlipped: Bool, zoomFactor: Double, manualRotationAngle: CGFloat = 0, ringLightColor: Color = .white) { + self.viewModel = viewModel + self.isMirrorFlipped = isMirrorFlipped + self.zoomFactor = zoomFactor + self.manualRotationAngle = manualRotationAngle + self.ringLightColor = ringLightColor + } + + func makeUIView(context: Context) -> CameraPreviewUIView { + let view = CameraPreviewUIView(viewModel: viewModel) + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + + // Add pinch-to-zoom gesture + let pinch = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch(_:))) + view.addGestureRecognizer(pinch) + + return view + } + + func updateUIView(_ uiView: CameraPreviewUIView, context: Context) { + // Update background color to match ring light + uiView.backgroundColor = UIColor(ringLightColor) + + // Update manual rotation + uiView.manualRotationAngle = manualRotationAngle + + // Force layout update + uiView.setNeedsLayout() + uiView.layoutIfNeeded() + + // Apply mirror transform based on settings + CATransaction.begin() + CATransaction.setDisableActions(true) + + if isMirrorFlipped { + uiView.previewLayer?.transform = CATransform3DMakeScale(-1, 1, 1) + } else { + uiView.previewLayer?.transform = CATransform3DIdentity + } + + CATransaction.commit() + + // Apply zoom if changed + context.coordinator.applyZoom(zoomFactor) + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModel: viewModel) + } + + class Coordinator: NSObject { + let viewModel: CameraViewModel + private var lastAppliedZoom: Double = 1.0 + + init(viewModel: CameraViewModel) { + self.viewModel = viewModel + } + + @MainActor + @objc func handlePinch(_ gesture: UIPinchGestureRecognizer) { + guard gesture.state == .changed else { return } + + let newZoom = max(1.0, min(5.0, viewModel.settings.currentZoomFactor * gesture.scale)) + viewModel.settings.currentZoomFactor = newZoom + gesture.scale = 1.0 + + applyZoom(newZoom) + } + + func applyZoom(_ zoom: Double) { + guard zoom != lastAppliedZoom else { return } + lastAppliedZoom = zoom + + if let device = viewModel.captureSession?.inputs.first.flatMap({ ($0 as? AVCaptureDeviceInput)?.device }) { + do { + try device.lockForConfiguration() + device.videoZoomFactor = max(1.0, min(zoom, device.activeFormat.videoMaxZoomFactor)) + device.unlockForConfiguration() + } catch { + print("Error setting zoom: \(error)") + } + } + } + } +} + +// MARK: - UIView subclass for camera preview + +class CameraPreviewUIView: UIView { + private weak var viewModel: CameraViewModel? + var previewLayer: AVCaptureVideoPreviewLayer? + + /// Manual rotation offset from user (0, 90, 180, 270) + var manualRotationAngle: CGFloat = 0 + + override class var layerClass: AnyClass { + AVCaptureVideoPreviewLayer.self + } + + init(viewModel: CameraViewModel) { + self.viewModel = viewModel + super.init(frame: .zero) + backgroundColor = .black + autoresizingMask = [.flexibleWidth, .flexibleHeight] + setupPreviewLayer() + + // Listen for orientation changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleOrientationChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func setupPreviewLayer() { + guard let viewModel = viewModel, + let session = viewModel.captureSession else { return } + + if let layer = self.layer as? AVCaptureVideoPreviewLayer { + layer.session = session + // Use .resizeAspect to show exactly what will be captured + // The ring light fills any letterbox areas naturally + layer.videoGravity = .resizeAspect + previewLayer = layer + viewModel.previewLayer = layer + + // Set initial orientation + updatePreviewOrientation() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + // Ensure the preview layer fills the entire view bounds + previewLayer?.frame = bounds + + // Setup layer if not already done (can happen if session was nil at init) + if previewLayer == nil { + setupPreviewLayer() + } + + // Update orientation on layout changes + updatePreviewOrientation() + } + + /// Tracks the last valid orientation for fallback + private var lastValidOrientation: UIDeviceOrientation = .portrait + + @objc private func handleOrientationChange() { + updatePreviewOrientation() + } + + private func updatePreviewOrientation() { + guard let connection = previewLayer?.connection else { return } + + // Get rotation angle based on device orientation + var deviceOrientation = UIDevice.current.orientation + + // If device orientation is flat or unknown, try to get from interface orientation + if !deviceOrientation.isValidInterfaceOrientation { + // Use last known good orientation, or get from window scene + if let windowScene = window?.windowScene { + switch windowScene.interfaceOrientation { + case .portrait: + deviceOrientation = .portrait + case .portraitUpsideDown: + deviceOrientation = .portraitUpsideDown + case .landscapeLeft: + deviceOrientation = .landscapeRight // Interface and device are inverted + case .landscapeRight: + deviceOrientation = .landscapeLeft // Interface and device are inverted + case .unknown: + deviceOrientation = lastValidOrientation + @unknown default: + deviceOrientation = lastValidOrientation + } + } else { + deviceOrientation = lastValidOrientation + } + } else { + // Store this as the last valid orientation + lastValidOrientation = deviceOrientation + } + + // Calculate base rotation angle (in degrees) for the preview layer + // For front camera in portrait: sensor is landscape, so rotate 90° + let baseRotationAngle: CGFloat + switch deviceOrientation { + case .portrait: + baseRotationAngle = 90 + case .portraitUpsideDown: + baseRotationAngle = 270 + case .landscapeLeft: + baseRotationAngle = 180 + case .landscapeRight: + baseRotationAngle = 0 + default: + baseRotationAngle = 90 // Default to portrait + } + + // Add manual rotation offset and normalize to 0-360 + let totalRotation = (baseRotationAngle + manualRotationAngle).truncatingRemainder(dividingBy: 360) + let finalRotation = totalRotation < 0 ? totalRotation + 360 : totalRotation + + // Use modern rotation angle API (iOS 17+) + if connection.isVideoRotationAngleSupported(finalRotation) { + connection.videoRotationAngle = finalRotation + } + } +} diff --git a/SelfieRingLight/Features/Camera/CameraViewModel.swift b/SelfieRingLight/Features/Camera/CameraViewModel.swift new file mode 100644 index 0000000..435539f --- /dev/null +++ b/SelfieRingLight/Features/Camera/CameraViewModel.swift @@ -0,0 +1,393 @@ +import AVFoundation +import SwiftUI +import Photos +import CoreImage +import UIKit +import Bedrock + +@MainActor +@Observable +class CameraViewModel: NSObject { + var isCameraAuthorized = false + var isPhotoLibraryAuthorized = false + var captureSession: AVCaptureSession? + var photoOutput: AVCapturePhotoOutput? + var videoOutput: AVCaptureMovieFileOutput? + var videoDataOutput: AVCaptureVideoDataOutput? + var previewLayer: AVCaptureVideoPreviewLayer? + var isUsingFrontCamera = true + var isRecording = false + var originalBrightness: CGFloat = 0.5 + var ciContext = CIContext() + + /// Whether the preview should be hidden (for front flash effect) + var isPreviewHidden = false + + /// Captured media for preview (nil when no capture pending) + var capturedMedia: CapturedMedia? + + /// Whether to show the post-capture preview + var showPostCapturePreview = false + + /// Toast message to display + var toastMessage: String? + + /// Whether Center Stage is available on this device + var isCenterStageAvailable = false + + /// Whether Center Stage is currently enabled + var isCenterStageEnabled = false + + /// Manual rotation offset (0, 90, 180, 270 degrees) + /// Allows user to rotate preview independent of device orientation + var manualRotationAngle: CGFloat = 0 + + let settings = SettingsViewModel() // Shared config + + // MARK: - Manual Rotation + + /// Cycles through rotation angles: 0 → 90 → 180 → 270 → 0 + func cycleManualRotation() { + manualRotationAngle = (manualRotationAngle + 90).truncatingRemainder(dividingBy: 360) + } + + /// Resets manual rotation to match device orientation + func resetManualRotation() { + manualRotationAngle = 0 + } + + // MARK: - Screen Brightness Handling + + /// Gets the current screen from any available window scene + private var currentScreen: UIScreen? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.screen + } + + private func saveCurrentBrightness() { + if let screen = currentScreen { + originalBrightness = screen.brightness + } + } + + private func setBrightness(_ value: CGFloat) { + currentScreen?.brightness = value + } + + func setupCamera() async { + isCameraAuthorized = await AVCaptureDevice.requestAccess(for: .video) + isPhotoLibraryAuthorized = await PHPhotoLibrary.requestAuthorization(for: .addOnly) == .authorized + + guard isCameraAuthorized else { return } + + captureSession = AVCaptureSession() + guard let session = captureSession else { return } + + session.beginConfiguration() + // Use .photo preset for optimal photo quality and consistent 4:3 aspect ratio + session.sessionPreset = .photo + + let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: isUsingFrontCamera ? .front : .back) + guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return } + if session.canAddInput(input) { + session.addInput(input) + } + + photoOutput = AVCapturePhotoOutput() + if let photoOutput, session.canAddOutput(photoOutput) { + session.addOutput(photoOutput) + } + + videoOutput = AVCaptureMovieFileOutput() + if let videoOutput, session.canAddOutput(videoOutput) { + session.addOutput(videoOutput) + } + + videoDataOutput = AVCaptureVideoDataOutput() + videoDataOutput?.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue")) + if let videoDataOutput, session.canAddOutput(videoDataOutput) { + session.addOutput(videoDataOutput) + } + + session.commitConfiguration() + session.startRunning() + + // Check Center Stage availability + updateCenterStageAvailability() + + UIApplication.shared.isIdleTimerDisabled = true + saveCurrentBrightness() + // Set screen to full brightness for best ring light effect + setBrightness(1.0) + } + + // MARK: - Center Stage + + /// Updates Center Stage availability based on current camera + private func updateCenterStageAvailability() { + isCenterStageAvailable = AVCaptureDevice.isCenterStageEnabled || checkCenterStageSupport() + isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled + } + + /// Checks if the current device supports Center Stage + private func checkCenterStageSupport() -> Bool { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { + return false + } + // Center Stage is available if the device has it as an active format feature + return device.activeFormat.isCenterStageSupported + } + + /// Toggles Center Stage on/off + func toggleCenterStage() { + guard isCenterStageAvailable else { return } + + AVCaptureDevice.centerStageControlMode = .app + AVCaptureDevice.isCenterStageEnabled.toggle() + isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled + } + + // MARK: - Orientation + + /// Updates video orientation based on device orientation + func updateVideoOrientation(for orientation: UIDeviceOrientation) { + guard let connection = photoOutput?.connection(with: .video) else { return } + + // Calculate rotation angle (in degrees) + let rotationAngle: CGFloat + switch orientation { + case .portrait: + rotationAngle = 90 + case .portraitUpsideDown: + rotationAngle = 270 + case .landscapeLeft: + rotationAngle = 0 + case .landscapeRight: + rotationAngle = 180 + default: + rotationAngle = 90 // Default to portrait + } + + // Use modern rotation angle API (iOS 17+) + if connection.isVideoRotationAngleSupported(rotationAngle) { + connection.videoRotationAngle = rotationAngle + } + + // Also update video output connection + if let videoConnection = videoOutput?.connection(with: .video), + videoConnection.isVideoRotationAngleSupported(rotationAngle) { + videoConnection.videoRotationAngle = rotationAngle + } + } + + func switchCamera() { + guard let session = captureSession else { return } + session.beginConfiguration() + session.inputs.forEach { session.removeInput($0) } + + isUsingFrontCamera.toggle() + let position: AVCaptureDevice.Position = isUsingFrontCamera ? .front : .back + let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) + guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return } + if session.canAddInput(input) { + session.addInput(input) + } + session.commitConfiguration() + + // Update Center Stage availability (only works on front camera) + updateCenterStageAvailability() + } + + func capturePhoto() { + // If front flash is enabled, hide the preview to show the ring light + if settings.isFrontFlashEnabled { + performFrontFlashCapture() + } else { + let captureSettings = AVCapturePhotoSettings() + photoOutput?.capturePhoto(with: captureSettings, delegate: self) + } + } + + /// Performs photo capture with front flash effect + private func performFrontFlashCapture() { + isPreviewHidden = true + + // Brief delay to show the full ring light before capturing + Task { + try? await Task.sleep(for: .milliseconds(150)) + + let captureSettings = AVCapturePhotoSettings() + photoOutput?.capturePhoto(with: captureSettings, delegate: self) + } + } + + /// Restores the preview after front flash capture + func restorePreviewAfterFlash() { + isPreviewHidden = false + } + + func startRecording() { + guard let videoOutput = videoOutput, !isRecording else { return } + let url = FileManager.default.temporaryDirectory.appendingPathComponent("video.mov") + videoOutput.startRecording(to: url, recordingDelegate: self) + isRecording = true + } + + func stopRecording() { + guard let videoOutput = videoOutput, isRecording else { return } + videoOutput.stopRecording() + isRecording = false + } + + func restoreBrightness() { + setBrightness(originalBrightness) + UIApplication.shared.isIdleTimerDisabled = false + } + + // Business logic: Check if ready to capture + var canCapture: Bool { + captureSession?.isRunning == true && isPhotoLibraryAuthorized + } + + // MARK: - Post-Capture Actions + + /// Dismisses the post-capture preview and returns to camera + func dismissPostCapturePreview() { + showPostCapturePreview = false + capturedMedia = nil + } + + /// Retakes by dismissing preview (deletes unsaved temp if needed) + func retakeCapture() { + // If auto-save was off and there's temp media, it's discarded + dismissPostCapturePreview() + } + + /// Manually saves current capture to Photo Library + func saveCurrentCapture() { + guard let media = capturedMedia else { return } + + switch media { + case .photo(let image): + if let data = image.jpegData(compressionQuality: 0.9) { + savePhotoToLibrary(data: data) + showToast(String(localized: "Saved to Photos")) + } + case .video(let url), .boomerang(let url): + saveVideoToLibrary(url: url) + showToast(String(localized: "Saved to Photos")) + } + } + + /// Gets shareable items for the current capture + func getShareItems() -> [Any] { + guard let media = capturedMedia else { return [] } + + switch media { + case .photo(let image): + return [image] + case .video(let url), .boomerang(let url): + return [url] + } + } + + // MARK: - Toast + + /// Shows a toast message briefly + func showToast(_ message: String) { + toastMessage = message + + Task { + try? await Task.sleep(for: .seconds(2)) + if toastMessage == message { + toastMessage = nil + } + } + } +} + +extension CameraViewModel: AVCapturePhotoCaptureDelegate { + nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + guard let data = photo.fileDataRepresentation(), + let image = UIImage(data: data) else { return } + + Task { @MainActor in + // Restore preview first (in case front flash was used) + restorePreviewAfterFlash() + + // Store the captured image for preview + capturedMedia = .photo(image) + + // Auto-save if enabled + if settings.isAutoSaveEnabled { + savePhotoToLibrary(data: data) + showToast(String(localized: "Saved to Photos")) + } + + // Show post-capture preview + showPostCapturePreview = true + + UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured")) + } + } + + /// Saves photo data to Photo Library + private func savePhotoToLibrary(data: Data) { + PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil) + } + } +} + +extension CameraViewModel: AVCaptureFileOutputRecordingDelegate { + nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + Task { @MainActor in + // Store the video URL for preview + let isBoomerang = settings.selectedCaptureMode == .boomerang + capturedMedia = isBoomerang ? .boomerang(outputFileURL) : .video(outputFileURL) + + // Auto-save if enabled + if settings.isAutoSaveEnabled { + saveVideoToLibrary(url: outputFileURL) + showToast(String(localized: "Saved to Photos")) + } + + // Show post-capture preview + showPostCapturePreview = true + + UIAccessibility.post(notification: .announcement, argument: String(localized: "Video saved")) + } + } + + /// Saves video to Photo Library + private func saveVideoToLibrary(url: URL) { + PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil) + } + } +} + +extension CameraViewModel: AVCaptureVideoDataOutputSampleBufferDelegate { + nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + // Note: This runs on a background queue and cannot access @MainActor isolated properties directly + // For real skin smoothing, this would need to be implemented with a Metal-based approach + // or by using AVCaptureVideoDataOutput with custom rendering + + // Basic skin smoothing placeholder - actual implementation would require: + // 1. CIContext created on this queue + // 2. Rendering to a Metal texture + // 3. Displaying via CAMetalLayer or similar + + guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + let ciImage = CIImage(cvPixelBuffer: imageBuffer) + + // Apply light gaussian blur for skin smoothing effect + guard let filter = CIFilter(name: "CIGaussianBlur") else { return } + filter.setValue(ciImage, forKey: kCIInputImageKey) + filter.setValue(1.0, forKey: kCIInputRadiusKey) + + // For a complete implementation, render outputImage to the preview layer + _ = filter.outputImage + } +} diff --git a/SelfieRingLight/Features/Camera/ContentView.swift b/SelfieRingLight/Features/Camera/ContentView.swift index 79e49ae..edf49a9 100644 --- a/SelfieRingLight/Features/Camera/ContentView.swift +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -1,119 +1,161 @@ import SwiftUI -import MijickCamera import Bedrock -import Photos -import AVFoundation struct ContentView: View { - @State private var settings = SettingsViewModel() + @State private var viewModel = CameraViewModel() @State private var premiumManager = PremiumManager() @State private var showPaywall = false @State private var showSettings = false - @State private var toastMessage: String? - - // Captured media for post-capture preview - @State private var capturedImage: UIImage? - @State private var capturedVideoURL: URL? - @State private var showPostCapturePreview = false - - // Center Stage support - @State private var isCenterStageAvailable = false - @State private var isCenterStageEnabled = false + @State private var showShareSheet = false var body: some View { GeometryReader { geometry in let maxRingSize = calculateMaxRingSize(for: geometry) - let effectiveRingSize = min(settings.ringSize, maxRingSize) + let effectiveRingSize = min(viewModel.settings.ringSize, maxRingSize) - // Use MCamera as the base, with ring light as border - MCamera() - .onImageCaptured { image, _ in - handleImageCaptured(image) + ZStack { + // MARK: - Ring Light Background + ringLightBackground + + // MARK: - Camera Preview (full screen with ring border) + cameraPreviewArea(ringSize: effectiveRingSize) + + // MARK: - Grid Overlay + if viewModel.settings.isGridVisible && !viewModel.isPreviewHidden { + GridOverlay(isVisible: true) + .padding(effectiveRingSize) } - .onVideoCaptured { url, _ in - handleVideoCaptured(url) + + // MARK: - Controls Overlay (on top of preview) + controlsOverlay(ringSize: effectiveRingSize) + + // MARK: - Permission Denied View + if !viewModel.isCameraAuthorized && viewModel.captureSession != nil { + permissionDeniedView } - .ignoresSafeArea() - .overlay { - // Ring light border overlay - ringLightOverlay(ringSize: effectiveRingSize) + + // MARK: - Toast Notification + if let message = viewModel.toastMessage { + toastView(message: message) } - .overlay { - // Grid overlay - if settings.isGridVisible { - GridOverlay(isVisible: true) - .padding(effectiveRingSize) - .allowsHitTesting(false) - } - } - .overlay { - // Top controls - VStack { - topControlBar - .padding(.top, effectiveRingSize + Design.Spacing.small) - Spacer() - } - .padding(.horizontal, effectiveRingSize + Design.Spacing.small) - } - .overlay { - // Toast - if let message = toastMessage { - toastView(message: message) - } - } - .onChange(of: geometry.size) { _, newSize in - let newMax = min(newSize.width, newSize.height) / 4 - if settings.ringSize > newMax { - settings.ringSize = newMax - } + } + .onChange(of: geometry.size) { _, newSize in + // Update max ring size when screen size changes + let newMax = min(newSize.width, newSize.height) / 4 + if viewModel.settings.ringSize > newMax { + viewModel.settings.ringSize = newMax } + } } - .onAppear { - checkCenterStageAvailability() + .ignoresSafeArea() + .task { + await viewModel.setupCamera() + } + .onDisappear { + viewModel.restoreBrightness() + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + viewModel.updateVideoOrientation(for: UIDevice.current.orientation) } .sheet(isPresented: $showPaywall) { ProPaywallView() } .sheet(isPresented: $showSettings) { - SettingsView(viewModel: settings, showPaywall: $showPaywall) + SettingsView(viewModel: viewModel.settings, showPaywall: $showPaywall) } - .fullScreenCover(isPresented: $showPostCapturePreview) { - postCaptureView + .fullScreenCover(isPresented: $viewModel.showPostCapturePreview) { + if let media = viewModel.capturedMedia { + PostCapturePreviewView( + media: media, + isPremiumUnlocked: premiumManager.isPremiumUnlocked, + onRetake: { + viewModel.retakeCapture() + }, + onSave: { + viewModel.saveCurrentCapture() + }, + onShare: { + showShareSheet = true + }, + onDismiss: { + viewModel.dismissPostCapturePreview() + } + ) + .sheet(isPresented: $showShareSheet) { + ShareSheet(items: viewModel.getShareItems()) + } + } } } // MARK: - Max Ring Size Calculation + /// Calculates maximum ring size based on screen dimensions + /// Ring should not exceed 1/4 of the smaller dimension private func calculateMaxRingSize(for geometry: GeometryProxy) -> CGFloat { min(geometry.size.width, geometry.size.height) / 4 } - // MARK: - Ring Light Overlay + // MARK: - Ring Light Background - /// Creates a ring light effect as a border around the camera @ViewBuilder - private func ringLightOverlay(ringSize: CGFloat) -> some View { - // Use a rectangle with a large inner cutout to create the ring effect - GeometryReader { geo in - let innerRect = CGRect( - x: ringSize, - y: ringSize, - width: geo.size.width - (ringSize * 2), - height: geo.size.height - (ringSize * 2) - ) - - settings.lightColor - .mask( - Rectangle() - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .frame(width: innerRect.width, height: innerRect.height) - .blendMode(.destinationOut) - ) - .compositingGroup() + private var ringLightBackground: some View { + // Always use the selected light color - premium checks are done in Settings + viewModel.settings.lightColor + .ignoresSafeArea() + } + + // MARK: - Camera Preview Area + + @ViewBuilder + private func cameraPreviewArea(ringSize: CGFloat) -> some View { + if viewModel.isCameraAuthorized { + // Show preview unless front flash is active + if !viewModel.isPreviewHidden { + CameraPreview( + viewModel: viewModel, + isMirrorFlipped: viewModel.settings.isMirrorFlipped, + zoomFactor: viewModel.settings.currentZoomFactor, + manualRotationAngle: viewModel.manualRotationAngle, + ringLightColor: viewModel.settings.lightColor ) - .allowsHitTesting(false) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .padding(ringSize) + .animation(.easeInOut(duration: Design.Animation.quick), value: ringSize) + .animation(.easeInOut(duration: Design.Animation.quick), value: viewModel.manualRotationAngle) + } + } else { + // Show placeholder while requesting permission + Rectangle() + .fill(viewModel.settings.lightColor) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .padding(ringSize) + .animation(.easeInOut(duration: Design.Animation.quick), value: ringSize) + .overlay { + if viewModel.captureSession == nil { + ProgressView() + .tint(.white) + .scaleEffect(1.5) + } + } } + } + + // MARK: - Controls Overlay + + private func controlsOverlay(ringSize: CGFloat) -> some View { + VStack { + // Top bar + topControlBar + .padding(.top, ringSize + Design.Spacing.small) + + Spacer() + + // Bottom capture controls + bottomControlBar + .padding(.bottom, ringSize + Design.Spacing.medium) + } + .padding(.horizontal, ringSize + Design.Spacing.small) .animation(.easeInOut(duration: Design.Animation.quick), value: ringSize) } @@ -122,35 +164,51 @@ struct ContentView: View { private var topControlBar: some View { HStack { // Center Stage button (only shown when available) - if isCenterStageAvailable { + if viewModel.isCenterStageAvailable { Button { - toggleCenterStage() + viewModel.toggleCenterStage() } label: { - Image(systemName: isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle") + Image(systemName: viewModel.isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle") .font(.body) - .foregroundStyle(isCenterStageEnabled ? .yellow : .white) + .foregroundStyle(viewModel.isCenterStageEnabled ? .yellow : .white) .padding(Design.Spacing.small) - .background(.ultraThinMaterial, in: Circle()) + .background(.ultraThinMaterial, in: .circle) } .accessibilityLabel(String(localized: "Center Stage")) - .accessibilityValue(isCenterStageEnabled ? "On" : "Off") + .accessibilityValue(viewModel.isCenterStageEnabled ? "On" : "Off") .accessibilityHint(String(localized: "Keeps you centered in frame")) } + // Rotate preview button + Button { + withAnimation(.easeInOut(duration: Design.Animation.quick)) { + viewModel.cycleManualRotation() + } + } label: { + Image(systemName: rotationIconName) + .font(.body) + .foregroundStyle(viewModel.manualRotationAngle != 0 ? .yellow : .white) + .padding(Design.Spacing.small) + .background(.ultraThinMaterial, in: .circle) + } + .accessibilityLabel(String(localized: "Rotate preview")) + .accessibilityValue(rotationAccessibilityValue) + .accessibilityHint(String(localized: "Rotates the camera preview 90 degrees")) + Spacer() // Grid toggle Button { - settings.isGridVisible.toggle() + viewModel.settings.isGridVisible.toggle() } label: { Image(systemName: "square.grid.3x3") .font(.body) - .foregroundStyle(settings.isGridVisible ? .yellow : .white) + .foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white) .padding(Design.Spacing.small) - .background(.ultraThinMaterial, in: Circle()) + .background(.ultraThinMaterial, in: .circle) } .accessibilityLabel(String(localized: "Toggle grid")) - .accessibilityValue(settings.isGridVisible ? "On" : "Off") + .accessibilityValue(viewModel.settings.isGridVisible ? "On" : "Off") // Settings button Button { @@ -160,52 +218,182 @@ struct ContentView: View { .font(.body) .foregroundStyle(.white) .padding(Design.Spacing.small) - .background(.ultraThinMaterial, in: Circle()) + .background(.ultraThinMaterial, in: .circle) } .accessibilityLabel(String(localized: "Settings")) } } - // MARK: - Center Stage - - private func checkCenterStageAvailability() { - guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { - isCenterStageAvailable = false - return + /// Icon name for rotation button based on current rotation + private var rotationIconName: String { + switch viewModel.manualRotationAngle { + case 90: + return "rotate.right.fill" + case 180: + return "arrow.up.arrow.down" + case 270: + return "rotate.left.fill" + default: + return "rotate.right" } - - isCenterStageAvailable = device.activeFormat.isCenterStageSupported - isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled } - private func toggleCenterStage() { - AVCaptureDevice.centerStageControlMode = .app - AVCaptureDevice.isCenterStageEnabled.toggle() - isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled + /// Accessibility value for rotation button + private var rotationAccessibilityValue: String { + switch viewModel.manualRotationAngle { + case 0: + return String(localized: "No rotation") + case 90: + return String(localized: "Rotated 90 degrees right") + case 180: + return String(localized: "Rotated 180 degrees") + case 270: + return String(localized: "Rotated 90 degrees left") + default: + return String(localized: "Custom rotation") + } } - // MARK: - Post Capture View + // MARK: - Bottom Control Bar - @ViewBuilder - private var postCaptureView: some View { - if let image = capturedImage { - PostCapturePreviewView( - media: .photo(image), - isPremiumUnlocked: premiumManager.isPremiumUnlocked, - onRetake: { dismissPostCapture() }, - onSave: { saveImage(image) }, - onShare: {}, - onDismiss: { dismissPostCapture() } - ) - } else if let url = capturedVideoURL { - PostCapturePreviewView( - media: .video(url), - isPremiumUnlocked: premiumManager.isPremiumUnlocked, - onRetake: { dismissPostCapture() }, - onSave: { saveVideo(url) }, - onShare: {}, - onDismiss: { dismissPostCapture() } - ) + private var bottomControlBar: some View { + HStack { + // Switch camera button + Button { + viewModel.switchCamera() + } label: { + Image(systemName: "camera.rotate.fill") + .font(.title2) + .foregroundStyle(.white) + .padding(Design.Spacing.medium) + .background(.ultraThinMaterial, in: .circle) + } + .accessibilityLabel(String(localized: "Switch camera")) + + Spacer() + + // Capture button + captureButton + + Spacer() + + // Capture mode selector + captureModeMenu + } + } + + // MARK: - Capture Button + + private var captureButton: some View { + Button { + captureAction() + } label: { + ZStack { + Circle() + .fill(.white) + .frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize) + + Circle() + .stroke(.white, lineWidth: Design.LineWidth.thick) + .frame(width: Design.Capture.buttonSize + Design.Spacing.small, height: Design.Capture.buttonSize + Design.Spacing.small) + + // Show red stop square when recording + if viewModel.isRecording { + RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall) + .fill(.red) + .frame(width: Design.Capture.stopSquare, height: Design.Capture.stopSquare) + } + } + } + .accessibilityLabel(captureButtonLabel) + .disabled(!viewModel.canCapture) + } + + // MARK: - Capture Mode Menu + + private var captureModeMenu: some View { + Menu { + ForEach(CaptureMode.allCases) { mode in + Button { + if !mode.isPremium || premiumManager.isPremiumUnlocked { + viewModel.settings.selectedCaptureMode = mode + } else { + showPaywall = true + } + } label: { + HStack { + Label(mode.displayName, systemImage: mode.systemImage) + if mode.isPremium && !premiumManager.isPremiumUnlocked { + Image(systemName: "crown.fill") + } + } + } + } + } label: { + Image(systemName: viewModel.settings.selectedCaptureMode.systemImage) + .font(.title2) + .foregroundStyle(.white) + .padding(Design.Spacing.medium) + .background(.ultraThinMaterial, in: .circle) + } + .accessibilityLabel(String(localized: "Capture mode: \(viewModel.settings.selectedCaptureMode.displayName)")) + } + + // MARK: - Permission Denied View + + private var permissionDeniedView: some View { + VStack(spacing: Design.Spacing.large) { + Image(systemName: "camera.fill") + .font(.system(size: Design.BaseFontSize.hero)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text("Camera Access Required") + .font(.title2.bold()) + .foregroundStyle(.white) + + Text("Please enable camera access in Settings to use SelfieRingLight.") + .font(.body) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + .multilineTextAlignment(.center) + .padding(.horizontal, Design.Spacing.xLarge) + + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(Design.Opacity.heavy)) + } + + // MARK: - Capture Action + + private func captureAction() { + switch viewModel.settings.selectedCaptureMode { + case .photo: + viewModel.capturePhoto() + case .video: + if viewModel.isRecording { + viewModel.stopRecording() + } else { + viewModel.startRecording() + } + case .boomerang: + // TODO: Implement boomerang capture + viewModel.capturePhoto() + } + } + + private var captureButtonLabel: String { + switch viewModel.settings.selectedCaptureMode { + case .photo: + return String(localized: "Take photo") + case .video: + return viewModel.isRecording ? String(localized: "Stop recording") : String(localized: "Start recording") + case .boomerang: + return String(localized: "Capture boomerang") } } @@ -227,61 +415,19 @@ struct ContentView: View { } .accessibilityLabel(message) } +} + +// MARK: - Share Sheet + +/// UIKit wrapper for UIActivityViewController +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] - // MARK: - Capture Handlers - - private func handleImageCaptured(_ image: UIImage) { - capturedImage = image - - if settings.isAutoSaveEnabled { - saveImage(image) - showToast(String(localized: "Saved to Photos")) - } - - showPostCapturePreview = true + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) } - private func handleVideoCaptured(_ url: URL) { - capturedVideoURL = url - - if settings.isAutoSaveEnabled { - saveVideo(url) - showToast(String(localized: "Saved to Photos")) - } - - showPostCapturePreview = true - } - - private func dismissPostCapture() { - showPostCapturePreview = false - capturedImage = nil - capturedVideoURL = nil - } - - private func saveImage(_ image: UIImage) { - guard let data = image.jpegData(compressionQuality: 0.9) else { return } - - PHPhotoLibrary.shared().performChanges { - PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil) - } - } - - private func saveVideo(_ url: URL) { - PHPhotoLibrary.shared().performChanges { - PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil) - } - } - - private func showToast(_ message: String) { - toastMessage = message - - Task { - try? await Task.sleep(for: .seconds(2)) - if toastMessage == message { - toastMessage = nil - } - } - } + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } #Preview {